From c2f07bd8d24e3de3bd60c254806c49c2aaa0267a Mon Sep 17 00:00:00 2001
From: Rocky <72559939+hkgood@users.noreply.github.com>
Date: Wed, 8 Jul 2026 18:13:56 +0800
Subject: [PATCH 1/3] feat(macos): add macOS menu-bar app and harden
cross-device iCloud sync
Introduce a standalone macOS menu-bar app (OSGKeyboardMac) that reuses the
platform-agnostic OSGKeyboardShared core: record -> cloud/local ASR -> polish
-> insert. Local mode uses Qwen3-ASR via mlx-swift-asr (macOS 15+, Apple
Silicon); iOS targets stay zero-SPM.
Harden iCloud sync for multi-device correctness:
- Per-field settings merge (appSettings.v2) so concurrent edits no longer
clobber each other's unrelated fields.
- Per-device usage statistics (G-Counter) that sum instead of max().
- Tombstoned dictionary/history merge so deletes propagate and entries can't
resurrect.
- API keys replicate via iCloud Keychain, never iCloud KVS JSON; pulling a
legacy blob without key fields no longer wipes local Keychain entries.
- Add a low-risk "Sync Now" action in Settings.
Fix Flow keyboard mic state: stay orange until the host publishes a real ready
contract, share a single MicVoiceAvailability gate, and self-heal stale
cross-process heartbeat jitter instead of getting stuck.
Extract shared storage (SpeechHistoryStore/UsageStatisticsStore,
ConfigurationStore) into OSGKeyboardShared and add tests for the new
sync/merge logic.
---
.gitignore | 6 +
CHANGELOG.md | 15 +
OSGKeyboard/AppIcon.icon/icon.json | 4 +-
.../OSGLogoWide.imageset/Contents.json | 16 +
.../OSGLogoWide.imageset/OSGLogoWide.svg | 7 +
OSGKeyboard/Resources/PrivacyPolicy.html | 20 +-
OSGKeyboard/Services/FlowSessionManager.swift | 188 +++++++-
.../Services/SpeechHistoryStore+iOS.swift | 24 +
OSGKeyboard/Services/SpeechHistoryStore.swift | 102 -----
.../Services/UsageStatisticsStore.swift | 103 -----
.../Views/Components/HomeStatsCard.swift | 6 +
OSGKeyboard/Views/FlowColdStartOverlay.swift | 234 ++++++----
OSGKeyboard/Views/HomeView.swift | 4 +
OSGKeyboard/Views/MainAppRoot.swift | 4 +-
.../Views/PersonalDictionaryView.swift | 4 +-
OSGKeyboard/Views/ProviderPickerSection.swift | 15 -
OSGKeyboard/Views/SettingsICloudSyncRow.swift | 38 +-
OSGKeyboard/en.lproj/Localizable.strings | 14 +-
OSGKeyboard/zh-Hans.lproj/Localizable.strings | 14 +-
OSGKeyboardExt/KeyboardViewController.swift | 4 +
.../Services/KeyboardConfigSync.swift | 6 +
.../Services/KeyboardFlowCoordinator.swift | 167 +++++--
OSGKeyboardExt/Views/KeyboardRootView.swift | 148 +++---
.../OSGLogo.imageset/OSGLogo.svg | 2 +-
.../FlowLiveActivityWidget.swift | 19 +-
OSGKeyboardMac/DashboardView.swift | 232 ++++++++++
OSGKeyboardMac/Info.plist | 39 ++
OSGKeyboardMac/MacAppContextService.swift | 86 ++++
OSGKeyboardMac/MacAppearance.swift | 75 +++
OSGKeyboardMac/MacAudioRecorder.swift | 112 +++++
OSGKeyboardMac/MacComponents.swift | 232 ++++++++++
OSGKeyboardMac/MacContentView.swift | 145 ++++++
OSGKeyboardMac/MacDictationPipeline.swift | 63 +++
OSGKeyboardMac/MacDictationViewModel.swift | 300 ++++++++++++
OSGKeyboardMac/MacDictionaryView.swift | 234 ++++++++++
OSGKeyboardMac/MacHistoryView.swift | 163 +++++++
OSGKeyboardMac/MacHotkeyService.swift | 67 +++
OSGKeyboardMac/MacICloudSyncBootstrap.swift | 40 ++
OSGKeyboardMac/MacICloudSyncRows.swift | 180 ++++++++
OSGKeyboardMac/MacL10n.swift | 17 +
OSGKeyboardMac/MacLocalASRService.swift | 103 +++++
OSGKeyboardMac/MacQwen3ASREngine.swift | 94 ++++
OSGKeyboardMac/MacQwen3LocalASR.swift | 39 ++
OSGKeyboardMac/MacRootView.swift | 131 ++++++
OSGKeyboardMac/MacSettingsView.swift | 431 ++++++++++++++++++
OSGKeyboardMac/MacSpeechLocalASR.swift | 61 +++
OSGKeyboardMac/MacTextInsertionService.swift | 61 +++
OSGKeyboardMac/MacTheme.swift | 49 ++
OSGKeyboardMac/OSGKeyboardMac.entitlements | 18 +
OSGKeyboardMac/OSGKeyboardMacApp.swift | 145 ++++++
.../AppGroupStore+ConfigurationStore.swift | 10 +
.../Configuration/ConfigurationStore.swift | 31 ++
.../DesignSystem/RecordButton.swift | 26 +-
.../Models/AppGroupConfiguration.swift | 27 +-
.../MicVoiceAvailability+Keyboard.swift | 45 ++
.../Models/MicVoiceAvailability.swift | 37 ++
.../Models/PersonalDictionary+Merging.swift | 62 ++-
.../Models/PersonalDictionary.swift | 22 +-
OSGKeyboardShared/Models/ProviderConfig.swift | 6 +-
OSGKeyboardShared/Models/ProviderLogo.swift | 24 +
.../Models/SpeechHistoryEntry.swift | 32 ++
.../Models/SyncedAppSettings.swift | 76 +--
.../Models/SyncedAppSettingsV2.swift | 237 ++++++++++
OSGKeyboardShared/Models/SyncedField.swift | 29 ++
.../Models/SyncedSpeechHistory.swift | 140 ++++++
.../Models/SyncedUsageStatisticsV2.swift | 148 ++++++
.../Models/UsageStatistics.swift | 86 ++++
OSGKeyboardShared/Services/ASRService.swift | 2 +-
.../Services/AppGroupStore.swift | 34 +-
.../Services/CloudASR/CloudASRClients.swift | 16 +-
.../Services/CloudASR/CloudASRService.swift | 4 +-
.../Services/FlowContinuousCapture.swift | 65 +++
.../Services/FlowSessionBridge.swift | 62 ++-
.../Services/FlowSessionDarwin.swift | 12 +
.../Services/FlowSessionKeys.swift | 7 +
.../Services/ICloudSync/AppCloudSync.swift | 36 +-
.../ICloudSync/CloudSyncContext.swift | 19 +
.../ICloudSync/SettingsCloudSync.swift | 114 ++++-
.../ICloudSync/SpeechHistoryCloudSync.swift | 128 ++++++
.../Services/ICloudSync/SyncDeviceID.swift | 20 +
.../ICloudSync/UsageStatisticsCloudSync.swift | 133 ++++++
.../Services/KeyboardState.swift | 3 +
OSGKeyboardShared/Services/Keychain.swift | 196 ++++----
.../PersonalDictionaryCloudSync.swift | 10 +-
.../Services/PolishingService.swift | 8 +-
.../Services/SpeechHistoryStorage.swift | 79 ++++
.../Services/SpeechHistoryStore.swift | 95 ++++
.../Services/UsageStatisticsStore.swift | 125 +++++
OSGKeyboardShared/en.lproj/Shared.strings | 102 +++++
.../zh-Hans.lproj/Shared.strings | 102 +++++
.../ConfigurationStoreTests.swift | 44 ++
OSGKeyboardTests/FlowSessionBridgeTests.swift | 41 ++
.../MicVoiceAvailabilityTests.swift | 80 ++++
.../PersonalDictionaryMergeTests.swift | 59 +++
OSGKeyboardTests/SettingsCloudSyncTests.swift | 158 ++++---
.../SpeechHistoryCloudSyncTests.swift | 138 ++++++
.../UsageStatisticsCloudSyncTests.swift | 134 ++++++
docs/privacy.html | 28 +-
project.yml | 102 +++++
99 files changed, 6735 insertions(+), 740 deletions(-)
create mode 100644 OSGKeyboard/Assets.xcassets/OSGLogoWide.imageset/Contents.json
create mode 100644 OSGKeyboard/Assets.xcassets/OSGLogoWide.imageset/OSGLogoWide.svg
create mode 100644 OSGKeyboard/Services/SpeechHistoryStore+iOS.swift
delete mode 100644 OSGKeyboard/Services/SpeechHistoryStore.swift
delete mode 100644 OSGKeyboard/Services/UsageStatisticsStore.swift
create mode 100644 OSGKeyboardMac/DashboardView.swift
create mode 100644 OSGKeyboardMac/Info.plist
create mode 100644 OSGKeyboardMac/MacAppContextService.swift
create mode 100644 OSGKeyboardMac/MacAppearance.swift
create mode 100644 OSGKeyboardMac/MacAudioRecorder.swift
create mode 100644 OSGKeyboardMac/MacComponents.swift
create mode 100644 OSGKeyboardMac/MacContentView.swift
create mode 100644 OSGKeyboardMac/MacDictationPipeline.swift
create mode 100644 OSGKeyboardMac/MacDictationViewModel.swift
create mode 100644 OSGKeyboardMac/MacDictionaryView.swift
create mode 100644 OSGKeyboardMac/MacHistoryView.swift
create mode 100644 OSGKeyboardMac/MacHotkeyService.swift
create mode 100644 OSGKeyboardMac/MacICloudSyncBootstrap.swift
create mode 100644 OSGKeyboardMac/MacICloudSyncRows.swift
create mode 100644 OSGKeyboardMac/MacL10n.swift
create mode 100644 OSGKeyboardMac/MacLocalASRService.swift
create mode 100644 OSGKeyboardMac/MacQwen3ASREngine.swift
create mode 100644 OSGKeyboardMac/MacQwen3LocalASR.swift
create mode 100644 OSGKeyboardMac/MacRootView.swift
create mode 100644 OSGKeyboardMac/MacSettingsView.swift
create mode 100644 OSGKeyboardMac/MacSpeechLocalASR.swift
create mode 100644 OSGKeyboardMac/MacTextInsertionService.swift
create mode 100644 OSGKeyboardMac/MacTheme.swift
create mode 100644 OSGKeyboardMac/OSGKeyboardMac.entitlements
create mode 100644 OSGKeyboardMac/OSGKeyboardMacApp.swift
create mode 100644 OSGKeyboardShared/Core/Configuration/AppGroupStore+ConfigurationStore.swift
create mode 100644 OSGKeyboardShared/Core/Configuration/ConfigurationStore.swift
create mode 100644 OSGKeyboardShared/Models/MicVoiceAvailability+Keyboard.swift
create mode 100644 OSGKeyboardShared/Models/MicVoiceAvailability.swift
create mode 100644 OSGKeyboardShared/Models/ProviderLogo.swift
create mode 100644 OSGKeyboardShared/Models/SpeechHistoryEntry.swift
create mode 100644 OSGKeyboardShared/Models/SyncedAppSettingsV2.swift
create mode 100644 OSGKeyboardShared/Models/SyncedField.swift
create mode 100644 OSGKeyboardShared/Models/SyncedSpeechHistory.swift
create mode 100644 OSGKeyboardShared/Models/SyncedUsageStatisticsV2.swift
create mode 100644 OSGKeyboardShared/Models/UsageStatistics.swift
create mode 100644 OSGKeyboardShared/Services/ICloudSync/CloudSyncContext.swift
create mode 100644 OSGKeyboardShared/Services/ICloudSync/SpeechHistoryCloudSync.swift
create mode 100644 OSGKeyboardShared/Services/ICloudSync/SyncDeviceID.swift
create mode 100644 OSGKeyboardShared/Services/ICloudSync/UsageStatisticsCloudSync.swift
create mode 100644 OSGKeyboardShared/Services/SpeechHistoryStorage.swift
create mode 100644 OSGKeyboardShared/Services/SpeechHistoryStore.swift
create mode 100644 OSGKeyboardShared/Services/UsageStatisticsStore.swift
create mode 100644 OSGKeyboardTests/ConfigurationStoreTests.swift
create mode 100644 OSGKeyboardTests/MicVoiceAvailabilityTests.swift
create mode 100644 OSGKeyboardTests/PersonalDictionaryMergeTests.swift
create mode 100644 OSGKeyboardTests/SpeechHistoryCloudSyncTests.swift
create mode 100644 OSGKeyboardTests/UsageStatisticsCloudSyncTests.swift
diff --git a/.gitignore b/.gitignore
index 268d72b..5b038ea 100644
--- a/.gitignore
+++ b/.gitignore
@@ -64,3 +64,9 @@ 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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 99652b3..2703ab3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+- **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** 条(与同步载荷一致)。
+
+### Fixed
+- **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. / **就绪后麦克风卡橙色**:单次跨进程心跳读数抖动不再把健康会话打成粘滞的「会话已结束」错误、强制麦克风变橙。「会话已结束」提示现仅在(不依赖心跳的)会话合约真正失效时触发;短暂读数抖动由就绪宽限期平滑,遗留的过期提示会在宿主重新就绪后自动恢复为绿色。
+
## [0.5.0] - 2026-07-07
### Added
diff --git a/OSGKeyboard/AppIcon.icon/icon.json b/OSGKeyboard/AppIcon.icon/icon.json
index a9635cd..7a85758 100644
--- a/OSGKeyboard/AppIcon.icon/icon.json
+++ b/OSGKeyboard/AppIcon.icon/icon.json
@@ -31,8 +31,6 @@
"circles" : [
"watchOS"
],
- "squares" : [
- "iOS"
- ]
+ "squares" : "shared"
}
}
\ No newline at end of file
diff --git a/OSGKeyboard/Assets.xcassets/OSGLogoWide.imageset/Contents.json b/OSGKeyboard/Assets.xcassets/OSGLogoWide.imageset/Contents.json
new file mode 100644
index 0000000..456e2b5
--- /dev/null
+++ b/OSGKeyboard/Assets.xcassets/OSGLogoWide.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images" : [
+ {
+ "filename" : "OSGLogoWide.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true,
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/OSGKeyboard/Assets.xcassets/OSGLogoWide.imageset/OSGLogoWide.svg b/OSGKeyboard/Assets.xcassets/OSGLogoWide.imageset/OSGLogoWide.svg
new file mode 100644
index 0000000..cabab92
--- /dev/null
+++ b/OSGKeyboard/Assets.xcassets/OSGLogoWide.imageset/OSGLogoWide.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/OSGKeyboard/Resources/PrivacyPolicy.html b/OSGKeyboard/Resources/PrivacyPolicy.html
index d5629d3..bb9e15a 100644
--- a/OSGKeyboard/Resources/PrivacyPolicy.html
+++ b/OSGKeyboard/Resources/PrivacyPolicy.html
@@ -22,15 +22,15 @@
OSGKeyboard Privacy Policy
-
Last updated: June 19, 2026
+
Last updated: July 8, 2026
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.
What we collect
Voice audio — captured only while you actively record. On-device mode transcribes locally with Apple’s speech APIs; raw audio is not uploaded by OSGKeyboard.
Transcribed text — 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.
- API credentials — stored in the iOS Keychain on your device and shared only between the main app and keyboard extension via an App Group.
- App preferences — engine mode, language, and keyboard settings stored in App Group UserDefaults on your device.
+ API credentials — 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).
+ App preferences — 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.
What we do not collect
@@ -51,8 +51,8 @@
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.
Data retention
-
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.
-
Voice history — 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.
+
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.
+
Voice history — 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.
Contact
Questions: open an issue at github.com/hkgood/OSGKeyboard .
@@ -60,15 +60,15 @@
OSGKeyboard 隐私政策
-
更新日期: 2026 年 6 月 19 日
+
更新日期: 2026 年 7 月 8 日
OSGKeyboard 是一款 iOS 自定义键盘,可将语音转为文字。本政策说明应用处理哪些数据及用途。
我们处理的数据
语音音频 — 仅在你主动录音时采集。本地模式在设备端通过 Apple 语音识别转写,OSGKeyboard 不会上传原始录音。
转写文字 — 云端润色模式下,最终文字(非音频)可能发送到你配置的 LLM 服务商以整理标点和格式。
- API 凭证 — 保存在设备 Keychain,仅通过 App Group 在主 App 与键盘扩展间共享。
- 应用偏好 — 引擎、语言等设置保存在设备 App Group 中。
+ API 凭证 — 保存在设备 Keychain,在主 App 与键盘扩展间共享。开启 iCloud 设置同步后,经 iCloud 钥匙串同步(非 iCloud KVS JSON)。
+ 应用偏好 — 引擎、语言等设置保存在 App Group。可选 iCloud 同步经私有 iCloud 账户镜像偏好、统计与语音历史。
我们不收集的内容
@@ -89,8 +89,8 @@
选择云端润色时,转写文字会发往你配置的 API,该服务商的隐私政策适用于相关请求。
数据保留
-
设置与 API Key 保留在设备上,直至卸载或重置。识别结果写入你正在使用的宿主 App,OSGKeyboard 不会长期存储。
-
语音历史 — 成功的转写可能保存在主 App「历史」页,仅供本机查看,不会上传,可随时在历史页清空或通过重置设置清除。
+
设置保留在设备上,直至卸载或重置。开启 iCloud 同步后,API 密钥走 iCloud 钥匙串;偏好、统计与历史可能经私有 iCloud 账户同步。
+
语音历史 — 成功转写可保存在主 App「历史」页(最多 300 条)。开启 iCloud 设置同步后,历史也可能在多设备间同步。
联系
问题反馈:github.com/hkgood/OSGKeyboard
diff --git a/OSGKeyboard/Services/FlowSessionManager.swift b/OSGKeyboard/Services/FlowSessionManager.swift
index 5096352..269d424 100644
--- a/OSGKeyboard/Services/FlowSessionManager.swift
+++ b/OSGKeyboard/Services/FlowSessionManager.swift
@@ -67,12 +67,16 @@ final class FlowSessionManager: ObservableObject {
private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
/// True while handling a keyboard-initiated `startflow` cold start.
private var isColdStartHandoff = false
+ private static let coldStartAudioProofTimeout: TimeInterval = 2.5
init() {
// Sessions are (re)started explicitly on app foreground via
// `activateOnForeground()`. We deliberately do NOT silently reattach a
// stored session here — after a force-quit that would resurrect capture
// (and keep a stale Live Activity alive) without the user re-opening.
+ capture.onEngineLiveChanged = { [weak self] _ in
+ self?.refreshHostReady()
+ }
}
// MARK: - Public
@@ -86,15 +90,17 @@ final class FlowSessionManager: ObservableObject {
if coldStart {
isColdStartHandoff = true
+ showColdStartPreparing()
}
reconcilePersistedFlowStateBeforeStart()
if isActive {
extendSession(duration: duration)
+ refreshHostReady()
if coldStart {
Task { @MainActor [weak self] in
- self?.handleColdStartAfterSessionReady()
+ await self?.prepareExistingSessionForColdStartReturn()
}
}
return
@@ -144,6 +150,10 @@ final class FlowSessionManager: ObservableObject {
guard AppGroup.isAvailable else { return }
guard AppPermissions.flowRequirementsMet else {
sessionWarning = permissionWarningMessage()
+ FlowSessionBridge.setHostReady(false)
+ if isColdStartHandoff {
+ showColdStartPermissionFailure()
+ }
FlowLiveActivityController.endSession()
return
}
@@ -151,6 +161,10 @@ final class FlowSessionManager: ObservableObject {
}
func dismissColdStartOverlay() {
+ guard coldStartContext?.state != .preparing else { return }
+ if isActive {
+ refreshHostReady()
+ }
coldStartContext = nil
isColdStartHandoff = false
}
@@ -160,11 +174,21 @@ final class FlowSessionManager: ObservableObject {
dismissColdStartOverlay()
}
+ func retryColdStartReadiness() {
+ guard AppGroup.isAvailable else { return }
+ startSession(coldStart: true)
+ }
+
+ func openColdStartPermissionSettings() {
+ AppPermissions.openSystemSettings()
+ }
+
func endSession() {
guard isActive else { return }
debug("Flow session ended")
- dismissColdStartOverlay()
+ coldStartContext = nil
+ isColdStartHandoff = false
startTask?.cancel()
startTask = nil
pollingTask?.cancel()
@@ -282,19 +306,62 @@ final class FlowSessionManager: ObservableObject {
if capture.running {
capture.reassertIfRunning()
+ if capture.engineHasRecentAudio() {
+ sessionWarning = nil
+ }
+ refreshHostReady()
return
}
do {
try capture.start()
+ sessionWarning = nil
debug("capture restarted after foreground")
+ refreshHostReady()
} catch {
let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
sessionWarning = message
debug("capture restart failed: \(message)")
+ refreshHostReady()
}
}
+ /// Publish whether the keyboard can start a new utterance without jumping to the host app.
+ private func refreshHostReady() {
+ guard isActive else {
+ FlowSessionBridge.setHostReady(false)
+ return
+ }
+
+ let pollingAlive = pollingTask != nil && pollingTask?.isCancelled != true
+ // Steady-state ready uses structural engine liveness. The stricter
+ // "recent audio frame" proof is reserved for cold-start handoff only
+ // (`waitForAudioProof`) so brief UI-driven session hiccups do not
+ // drop the keyboard back to orange while the host app is foreground.
+ let canAcceptUtterance = capture.engineIsLive
+ && pollingAlive
+ && !isUtteranceRecording
+ && !isUtteranceProcessing
+ && sessionWarning == nil
+
+ FlowSessionBridge.setHostReady(canAcceptUtterance)
+ }
+
+ /// Home preview field gained focus while this app is the Flow host.
+ /// Reactivate capture and refresh the App Group ready contract so the
+ /// custom keyboard extension sees green immediately.
+ func refreshForInlineKeyboardFocus() async {
+ guard isActive else { return }
+ await reactivateCaptureIfNeeded()
+ refreshHostReady()
+ if !FlowSessionBridge.isHostReady() {
+ try? await Task.sleep(nanoseconds: 150_000_000)
+ await reactivateCaptureIfNeeded()
+ refreshHostReady()
+ }
+ FlowSessionBridge.writeHeartbeat()
+ }
+
/// Extend expiry after utterance completion based on the inactivity policy.
private func touchSessionActivity() {
guard isActive else { return }
@@ -317,6 +384,10 @@ final class FlowSessionManager: ObservableObject {
guard AppPermissions.flowRequirementsMet else {
sessionWarning = permissionWarningMessage()
+ FlowSessionBridge.setHostReady(false)
+ if isColdStartHandoff {
+ showColdStartPermissionFailure()
+ }
isColdStartHandoff = false
return
}
@@ -326,11 +397,26 @@ final class FlowSessionManager: ObservableObject {
} catch {
let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
sessionWarning = message
- isColdStartHandoff = false
+ FlowSessionBridge.setHostReady(false)
+ if isColdStartHandoff {
+ showColdStartAudioFailure(message: message)
+ }
debug("continuous capture failed: \(message)")
return
}
+ guard await waitForAudioProof() else {
+ let message = AppL10n.string("flow.coldStart.error.audioTimeout")
+ sessionWarning = message
+ capture.stop()
+ FlowSessionBridge.setHostReady(false)
+ if isColdStartHandoff {
+ showColdStartAudioFailure(message: message)
+ }
+ debug("continuous capture did not produce audio frames before timeout")
+ return
+ }
+
let resolvedDuration = duration ?? FlowSessionPolicy.sessionDuration()
FlowSessionBridge.markSessionActive(duration: resolvedDuration)
FlowSessionDarwin.postSessionChanged()
@@ -347,22 +433,79 @@ final class FlowSessionManager: ObservableObject {
scheduleASRWarmup()
FlowLiveActivityController.startSession()
+ refreshHostReady()
debug("Flow session started (\(Int(resolvedDuration))s inactivity window), continuous capture running")
}
+ private func prepareExistingSessionForColdStartReturn() async {
+ guard isColdStartHandoff, isActive else { return }
+ await reactivateCaptureIfNeeded()
+ guard await waitForAudioProof() else {
+ let message = AppL10n.string("flow.coldStart.error.audioTimeout")
+ sessionWarning = message
+ FlowSessionBridge.setHostReady(false)
+ showColdStartAudioFailure(message: message)
+ debug("existing session failed cold-start audio proof")
+ return
+ }
+ sessionWarning = nil
+ refreshHostReady()
+ handleColdStartAfterSessionReady()
+ }
+
+ private func waitForAudioProof() async -> Bool {
+ await capture.awaitAudioFlowing(timeout: Self.coldStartAudioProofTimeout)
+ }
+
@MainActor
private func handleColdStartAfterSessionReady() {
guard isColdStartHandoff, isActive else { return }
- let hostEntry = HostReturnService.pendingHostEntry()
- let skipSwitch = FlowSessionPolicy.skipAppSwitch()
-
- if skipSwitch, hostEntry != nil, HostReturnService.openPendingHostIfPossible() {
- dismissColdStartOverlay()
+ refreshHostReady()
+ guard FlowSessionBridge.isHostReady() else {
+ let message = AppL10n.string("flow.coldStart.error.audioTimeout")
+ sessionWarning = message
+ showColdStartAudioFailure(message: message)
+ debug("cold-start blocked: host ready contract not published")
return
}
- coldStartContext = FlowColdStartContext(hostEntry: hostEntry)
+ let hostEntry = HostReturnService.pendingHostEntry()
+ let skipSwitch = FlowSessionPolicy.skipAppSwitch()
+ coldStartContext = FlowColdStartContext(hostEntry: hostEntry, state: .ready)
+
+ if skipSwitch, hostEntry != nil {
+ Task { @MainActor [weak self] in
+ try? await Task.sleep(nanoseconds: 450_000_000)
+ guard let self, self.coldStartContext?.state == .ready else { return }
+ if HostReturnService.openPendingHostIfPossible() {
+ self.dismissColdStartOverlay()
+ }
+ }
+ }
+ }
+
+ private func showColdStartPreparing() {
+ coldStartContext = FlowColdStartContext(
+ hostEntry: HostReturnService.pendingHostEntry(),
+ state: .preparing
+ )
+ }
+
+ private func showColdStartPermissionFailure() {
+ FlowSessionBridge.setHostReady(false)
+ coldStartContext = FlowColdStartContext(
+ hostEntry: HostReturnService.pendingHostEntry(),
+ state: .failed(.permission(message: permissionWarningMessage()))
+ )
+ }
+
+ private func showColdStartAudioFailure(message: String) {
+ FlowSessionBridge.setHostReady(false)
+ coldStartContext = FlowColdStartContext(
+ hostEntry: HostReturnService.pendingHostEntry(),
+ state: .failed(.audio(message: message))
+ )
}
private func bindSessionASRIfNeeded(force: Bool = false) {
@@ -434,8 +577,16 @@ final class FlowSessionManager: ObservableObject {
guard !isUtteranceRecording, !isUtteranceProcessing else { return }
beginUtterance()
case .stopped:
- guard isUtteranceRecording else { return }
- endUtterance()
+ if isUtteranceRecording {
+ endUtterance()
+ } else if !isUtteranceProcessing {
+ FlowSessionBridge.setRecordingState(.idle)
+ FlowSessionBridge.storeTranscriptionError(
+ AppL10n.string("flow.error.recognitionInterrupted"),
+ kind: .recognitionInterrupted
+ )
+ debug("stopped without active utterance — notified keyboard")
+ }
case .aborted:
abortUtterance()
case .idle, .processing:
@@ -444,7 +595,7 @@ final class FlowSessionManager: ObservableObject {
}
private func beginUtterance() {
- guard capture.running else {
+ guard capture.engineIsLive else {
failUtterance(
message: AppL10n.string("flow.error.audioUnavailable"),
kind: .audioUnavailable
@@ -474,6 +625,7 @@ final class FlowSessionManager: ObservableObject {
isUtteranceRecording = true
utteranceRecordingStartedAt = Date()
+ refreshHostReady()
FlowLiveActivityController.update(phase: .recording)
FlowDiagnostics.log(
"beginUtterance engine=\(store.engineMode) " +
@@ -528,6 +680,7 @@ final class FlowSessionManager: ObservableObject {
FlowSessionBridge.setRecordingState(.processing)
isUtteranceRecording = false
isUtteranceProcessing = true
+ refreshHostReady()
FlowLiveActivityController.update(phase: .processing)
// Do NOT cancel `asrTask` or `asr` — drain trailing PCM, then finalize.
@@ -559,6 +712,7 @@ final class FlowSessionManager: ObservableObject {
FlowSessionBridge.storeTranscriptionPartial("")
FlowSessionBridge.setRecordingState(.idle)
FlowLiveActivityController.update(phase: .idle)
+ refreshHostReady()
debug("utterance aborted")
}
@@ -583,6 +737,7 @@ final class FlowSessionManager: ObservableObject {
FlowSessionBridge.storeTranscriptionError(message, kind: kind)
FlowSessionBridge.setRecordingState(.idle)
FlowLiveActivityController.update(phase: .idle)
+ refreshHostReady()
debug("utterance failed: \(message)")
}
@@ -602,6 +757,7 @@ final class FlowSessionManager: ObservableObject {
FlowSessionBridge.storeTranscriptionError(message, kind: kind)
FlowSessionBridge.setRecordingState(.idle)
FlowLiveActivityController.update(phase: .idle)
+ refreshHostReady()
debug("utterance processing failed: \(message)")
}
@@ -612,6 +768,7 @@ final class FlowSessionManager: ObservableObject {
FlowSessionBridge.setRecordingState(.idle)
FlowLiveActivityController.update(phase: .idle)
touchSessionActivity()
+ refreshHostReady()
}
let asrWait = asrWaitTimeout()
@@ -808,9 +965,14 @@ final class FlowSessionManager: ObservableObject {
FlowSessionBridge.writeHeartbeat()
heartbeatTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
+ guard let self else { break }
+ if self.isActive, !self.capture.engineIsLive {
+ await self.reactivateCaptureIfNeeded()
+ }
FlowSessionBridge.writeHeartbeat()
+ self.refreshHostReady()
try? await Task.sleep(nanoseconds: 1_000_000_000)
- guard self?.isActive == true else { break }
+ guard self.isActive else { break }
}
}
}
diff --git a/OSGKeyboard/Services/SpeechHistoryStore+iOS.swift b/OSGKeyboard/Services/SpeechHistoryStore+iOS.swift
new file mode 100644
index 0000000..6457a2c
--- /dev/null
+++ b/OSGKeyboard/Services/SpeechHistoryStore+iOS.swift
@@ -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
+ )
+ }
+}
diff --git a/OSGKeyboard/Services/SpeechHistoryStore.swift b/OSGKeyboard/Services/SpeechHistoryStore.swift
deleted file mode 100644
index ed61442..0000000
--- a/OSGKeyboard/Services/SpeechHistoryStore.swift
+++ /dev/null
@@ -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)
- }
-}
diff --git a/OSGKeyboard/Services/UsageStatisticsStore.swift b/OSGKeyboard/Services/UsageStatisticsStore.swift
deleted file mode 100644
index 387c447..0000000
--- a/OSGKeyboard/Services/UsageStatisticsStore.swift
+++ /dev/null
@@ -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)
- }
-}
diff --git a/OSGKeyboard/Views/Components/HomeStatsCard.swift b/OSGKeyboard/Views/Components/HomeStatsCard.swift
index 7a47b7c..9b42a7d 100644
--- a/OSGKeyboard/Views/Components/HomeStatsCard.swift
+++ b/OSGKeyboard/Views/Components/HomeStatsCard.swift
@@ -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 {
diff --git a/OSGKeyboard/Views/FlowColdStartOverlay.swift b/OSGKeyboard/Views/FlowColdStartOverlay.swift
index 90c5172..30f997c 100644
--- a/OSGKeyboard/Views/FlowColdStartOverlay.swift
+++ b/OSGKeyboard/Views/FlowColdStartOverlay.swift
@@ -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,155 @@ struct FlowColdStartOverlay: View {
let context: FlowColdStartContext
let onReturnToHost: () -> Void
let onDismiss: () -> Void
+ let onRetry: () -> Void
+ let onOpenSettings: () -> Void
- @State private var swipeOffset: CGFloat = 0
-
- private let homeBarWidth: CGFloat = 134
+ /// Fraction of the screen height the bottom gradient occupies.
+ private let gradientHeightFraction: CGFloat = 0.50
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.lg) {
+ content
+ .padding(.horizontal, Spacing.xl)
- 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)
- }
+ homeIndicator
+ .padding(.bottom, max(geo.safeAreaInsets.bottom, Spacing.sm))
}
- .padding(.horizontal, Spacing.xl)
+ .allowsHitTesting(false)
- Spacer()
-
- bottomSwipeGuide
- .padding(.bottom, Spacing.md)
-
- Text("flow.coldStart.tapToDismiss")
- .font(TypeStyle.caption)
- .foregroundStyle(palette.textTertiary)
- .padding(.bottom, Spacing.xl)
+ if context.state == .ready {
+ Color.clear
+ .contentShape(Rectangle())
+ .onTapGesture(perform: onDismiss)
+ .ignoresSafeArea()
+ }
}
+ .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)
+ }
}
diff --git a/OSGKeyboard/Views/HomeView.swift b/OSGKeyboard/Views/HomeView.swift
index e8af33a..7cf4623 100644
--- a/OSGKeyboard/Views/HomeView.swift
+++ b/OSGKeyboard/Views/HomeView.swift
@@ -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() {
diff --git a/OSGKeyboard/Views/MainAppRoot.swift b/OSGKeyboard/Views/MainAppRoot.swift
index f10849c..1be9f85 100644
--- a/OSGKeyboard/Views/MainAppRoot.swift
+++ b/OSGKeyboard/Views/MainAppRoot.swift
@@ -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)
}
diff --git a/OSGKeyboard/Views/PersonalDictionaryView.swift b/OSGKeyboard/Views/PersonalDictionaryView.swift
index 558022c..0c4aaf5 100644
--- a/OSGKeyboard/Views/PersonalDictionaryView.swift
+++ b/OSGKeyboard/Views/PersonalDictionaryView.swift
@@ -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()
}
diff --git a/OSGKeyboard/Views/ProviderPickerSection.swift b/OSGKeyboard/Views/ProviderPickerSection.swift
index 586a673..22934eb 100644
--- a/OSGKeyboard/Views/ProviderPickerSection.swift
+++ b/OSGKeyboard/Views/ProviderPickerSection.swift
@@ -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
- }
- }
-}
diff --git a/OSGKeyboard/Views/SettingsICloudSyncRow.swift b/OSGKeyboard/Views/SettingsICloudSyncRow.swift
index 4da1ddf..7a89baa 100644
--- a/OSGKeyboard/Views/SettingsICloudSyncRow.swift
+++ b/OSGKeyboard/Views/SettingsICloudSyncRow.swift
@@ -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:
diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings
index 772c633..abff350 100644
--- a/OSGKeyboard/en.lproj/Localizable.strings
+++ b/OSGKeyboard/en.lproj/Localizable.strings
@@ -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 %@";
diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings
index 8c7f611..c0bb312 100644
--- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings
+++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings
@@ -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" = "返回%@";
diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift
index d3f8ed7..ab5b6f4 100644
--- a/OSGKeyboardExt/KeyboardViewController.swift
+++ b/OSGKeyboardExt/KeyboardViewController.swift
@@ -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
}
diff --git a/OSGKeyboardExt/Services/KeyboardConfigSync.swift b/OSGKeyboardExt/Services/KeyboardConfigSync.swift
index 1c4216e..74bd8fd 100644
--- a/OSGKeyboardExt/Services/KeyboardConfigSync.swift
+++ b/OSGKeyboardExt/Services/KeyboardConfigSync.swift
@@ -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
diff --git a/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift b/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift
index 616d3cb..3470fb6 100644
--- a/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift
+++ b/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift
@@ -32,12 +32,16 @@ final class KeyboardFlowCoordinator {
private var isFlowRecording = false
private var flowWatchdogTask: Task
?
private var utteranceTimerTask: Task?
+ private var hostReadyWaitTask: Task?
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?
private var isAwaitingFlowResult = false
- private var lastFlowAutoStartAttempt: TimeInterval = 0
- private static let flowAutoStartCooldown: TimeInterval = 20
init(
state: KeyboardState,
@@ -82,9 +86,11 @@ final class KeyboardFlowCoordinator {
func stopSessionMonitor() {
flowSessionMonitorTask?.cancel()
flowSessionMonitorTask = nil
+ stopHostReadyWait()
}
func refreshSessionState() {
+ FlowSessionBridge.reloadFromDisk()
refreshConfigFromAppGroup()
refreshFlowPartialIfNeeded()
consumePendingFlowDeliveryIfNeeded()
@@ -95,10 +101,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 +128,65 @@ final class KeyboardFlowCoordinator {
showFlowSessionExpiredHint()
}
}
- wasFlowSessionActive = reachable
+ wasSessionActive = sessionActive
+ }
- maybeAutoStartFlowSession()
+ private func recomputeMicVoiceAvailability() {
+ FlowSessionBridge.reloadFromDisk()
+ let hostReady = 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 = hostReady
+ state.micVoiceAvailability = MicVoiceAvailabilityResolver.resolve(
+ phase: state.phase,
+ micDisabled: state.micDisabled,
+ hasFullAccess: hasFullAccess(),
+ appGroupAvailable: AppGroup.isAvailable,
+ hostReady: hostReady,
+ isPreparingSession: isPendingFlowStart || hostWarming
+ )
+ }
+
+ /// 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 +207,33 @@ 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()
+ recomputeMicVoiceAvailability()
+ case .unavailable(.preparingSession):
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 {
+ case .unavailable(.hostNotReady):
+ detectAndStoreAppContext()
beginFlowStart()
+ case .recording, .processing:
+ return
}
}
@@ -172,6 +251,7 @@ final class KeyboardFlowCoordinator {
debug("pressEnded wrote .stopped (readback=\(FlowSessionBridge.recordingState().rawValue))")
state.phase = .processing
state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing")
+ recomputeMicVoiceAvailability()
startFlowResultWatchdog()
}
@@ -181,7 +261,7 @@ final class KeyboardFlowCoordinator {
isFlowRecording = false
flowStartDeadline = Date().timeIntervalSince1970 + FlowWatchdog.startTimeout
state.lastTranscript = ExtL10n.string("keyboard.flow.startingSession")
- state.phase = .processing
+ recomputeMicVoiceAvailability()
openHostApp("startflow")
startFlowStartWatchdog()
debug("beginFlowStart")
@@ -199,6 +279,7 @@ final class KeyboardFlowCoordinator {
flowStartDeadline = 0
stopFlowWatchdog()
showManualOpenHint(path: "startflow")
+ recomputeMicVoiceAvailability()
return
}
@@ -217,6 +298,7 @@ final class KeyboardFlowCoordinator {
stopUtteranceCountdown()
stopFlowWatchdog()
state.level = 0
+ recomputeMicVoiceAvailability()
}
}
@@ -238,11 +320,12 @@ final class KeyboardFlowCoordinator {
message: error.message
)
scheduleAutoClearError()
+ recomputeMicVoiceAvailability()
return
}
}
- if isPendingFlowStart, FlowSessionBridge.isHostReachable() {
+ if isPendingFlowStart, FlowSessionBridge.isHostReady() {
completeFlowStartHandoff()
}
}
@@ -261,6 +344,7 @@ final class KeyboardFlowCoordinator {
state.level = 0
state.phase = .idle
state.lastTranscript = ""
+ recomputeMicVoiceAvailability()
debug("aborted recording — host heartbeat zombie")
return
}
@@ -270,20 +354,6 @@ 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() {
isAwaitingFlowResult = false
isFlowRecording = false
@@ -296,6 +366,7 @@ final class KeyboardFlowCoordinator {
let message = ExtL10n.string("keyboard.flow.hostDisconnected")
state.phase = .error(.flowSessionExpired, message: message)
scheduleAutoClearError()
+ recomputeMicVoiceAvailability()
debug("host disconnected while awaiting Flow result")
}
@@ -303,6 +374,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 +390,12 @@ final class KeyboardFlowCoordinator {
}
state.phase = .error(.manualOpenRequired, message: msg)
scheduleAutoClearError()
+ recomputeMicVoiceAvailability()
}
private func startFlowRecording() {
- guard FlowSessionBridge.isHostReachable() else {
+ recomputeMicVoiceAvailability()
+ guard state.micVoiceAvailability.isReady else {
beginFlowStart()
return
}
@@ -334,6 +408,7 @@ final class KeyboardFlowCoordinator {
isFlowRecording = true
state.lastTranscript = ""
state.phase = .recording
+ recomputeMicVoiceAvailability()
if let view = wakeLockView() {
ExtensionScreenWakeLock.acquire(from: view)
}
@@ -374,13 +449,15 @@ final class KeyboardFlowCoordinator {
stopFlowWatchdog()
state.phase = .idle
state.lastTranscript = ""
+ recomputeMicVoiceAvailability()
}
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
}
@@ -456,6 +533,7 @@ final class KeyboardFlowCoordinator {
message: error.message
)
self.scheduleAutoClearError()
+ self.recomputeMicVoiceAvailability()
return
}
self.refreshFlowPartialIfNeeded()
@@ -479,6 +557,7 @@ final class KeyboardFlowCoordinator {
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)
diff --git a/OSGKeyboardExt/Views/KeyboardRootView.swift b/OSGKeyboardExt/Views/KeyboardRootView.swift
index 08e284c..04cc0b1 100644
--- a/OSGKeyboardExt/Views/KeyboardRootView.swift
+++ b/OSGKeyboardExt/Views/KeyboardRootView.swift
@@ -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")
diff --git a/OSGKeyboardLiveActivity/Assets.xcassets/OSGLogo.imageset/OSGLogo.svg b/OSGKeyboardLiveActivity/Assets.xcassets/OSGLogo.imageset/OSGLogo.svg
index 36805f0..10f0872 100644
--- a/OSGKeyboardLiveActivity/Assets.xcassets/OSGLogo.imageset/OSGLogo.svg
+++ b/OSGKeyboardLiveActivity/Assets.xcassets/OSGLogo.imageset/OSGLogo.svg
@@ -1,4 +1,4 @@
-
+
diff --git a/OSGKeyboardLiveActivity/FlowLiveActivityWidget.swift b/OSGKeyboardLiveActivity/FlowLiveActivityWidget.swift
index f6b256d..c3af7cb 100644
--- a/OSGKeyboardLiveActivity/FlowLiveActivityWidget.swift
+++ b/OSGKeyboardLiveActivity/FlowLiveActivityWidget.swift
@@ -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: 18)
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")
}
}
diff --git a/OSGKeyboardMac/DashboardView.swift b/OSGKeyboardMac/DashboardView.swift
new file mode 100644
index 0000000..0fe37c6
--- /dev/null
+++ b/OSGKeyboardMac/DashboardView.swift
@@ -0,0 +1,232 @@
+// 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)
+ }
+ statGrid
+ dictationCanvas
+ }
+ .padding(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) {
+ 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)
+ .frame(maxWidth: .infinity, minHeight: 220, alignment: .topLeading)
+ } else {
+ Text(viewModel.transcript)
+ .font(.system(size: 22, weight: .regular))
+ .foregroundStyle(palette.textPrimary)
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, minHeight: 220, alignment: .topLeading)
+ }
+ }
+ }
+}
+
+// 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: 0.78)
+ .overlay(
+ RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
+ .stroke(palette.dividerStrong, lineWidth: 0.5)
+ )
+ .shadow(color: palette.textPrimary.opacity(0.12), radius: 18, y: 8)
+ }
+
+ 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)
+ }
+ .padding(.horizontal, Spacing.sm)
+ .padding(.vertical, 5)
+ .background(palette.surfaceElevated, in: Capsule())
+ }
+
+ 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()
+ }
+
+ private var recordControl: some View {
+ HStack(spacing: Spacing.sm) {
+ if viewModel.isRecording {
+ MiniWaveform(level: viewModel.audioLevel)
+ }
+ Button(action: viewModel.toggleRecording) {
+ ZStack {
+ Circle()
+ .fill(viewModel.isRecording ? palette.recordRed : palette.accent)
+ .frame(width: 52, height: 52)
+ .macGlassSurface(in: Circle(), fillOpacity: 0.2)
+ .shadow(
+ color: (viewModel.isRecording ? palette.recordRed : palette.accent).opacity(0.5),
+ radius: pulse ? 14 : 6
+ )
+ Image(systemName: viewModel.isRecording ? "stop.fill" : "mic.fill")
+ .font(.system(size: 20, weight: .bold))
+ .foregroundStyle(palette.textOnAccent)
+ }
+ }
+ .buttonStyle(.plain)
+ .disabled(viewModel.isProcessing)
+ .onAppear {
+ withAnimation(.easeInOut(duration: 1.1).repeatForever(autoreverses: true)) {
+ pulse = true
+ }
+ }
+ if viewModel.isRecording {
+ Text(MacL10n.string("mac.record.pressStop", language: lang))
+ .font(TypeStyle.caption)
+ .foregroundStyle(palette.textTertiary)
+ }
+ }
+ }
+
+ 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
+ }
+}
diff --git a/OSGKeyboardMac/Info.plist b/OSGKeyboardMac/Info.plist
new file mode 100644
index 0000000..c8f8da3
--- /dev/null
+++ b/OSGKeyboardMac/Info.plist
@@ -0,0 +1,39 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleDisplayName
+ OSGKeyboard
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleLocalizations
+
+ en
+ zh-Hans
+
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ ITSAppUsesNonExemptEncryption
+
+ LSApplicationCategoryType
+ public.app-category.utilities
+ NSHumanReadableCopyright
+ OSGKeyboard
+ NSMicrophoneUsageDescription
+ OSGKeyboard uses the microphone to transcribe your voice via your configured cloud speech provider.
+ NSSpeechRecognitionUsageDescription
+ OSGKeyboard uses on-device speech recognition for local dictation mode.
+
+
diff --git a/OSGKeyboardMac/MacAppContextService.swift b/OSGKeyboardMac/MacAppContextService.swift
new file mode 100644
index 0000000..099ff1f
--- /dev/null
+++ b/OSGKeyboardMac/MacAppContextService.swift
@@ -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 = {
+ 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)
+ }
+}
diff --git a/OSGKeyboardMac/MacAppearance.swift b/OSGKeyboardMac/MacAppearance.swift
new file mode 100644
index 0000000..011094b
--- /dev/null
+++ b/OSGKeyboardMac/MacAppearance.swift
@@ -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 }
+ }
+ }
+}
diff --git a/OSGKeyboardMac/MacAudioRecorder.swift b/OSGKeyboardMac/MacAudioRecorder.swift
new file mode 100644
index 0000000..135a3b2
--- /dev/null
+++ b/OSGKeyboardMac/MacAudioRecorder.swift
@@ -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
+ }
+ }
+}
diff --git a/OSGKeyboardMac/MacComponents.swift b/OSGKeyboardMac/MacComponents.swift
new file mode 100644
index 0000000..abd509b
--- /dev/null
+++ b/OSGKeyboardMac/MacComponents.swift
@@ -0,0 +1,232 @@
+// 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: ViewModifier {
+ @Environment(\.themePalette) private var palette
+ let shape: S
+ let fillOpacity: Double
+
+ func body(content: Content) -> some View {
+ if #available(macOS 26.0, *) {
+ content
+ .background(palette.surface.opacity(fillOpacity), in: shape)
+ .glassEffect(.regular, in: shape)
+ } else {
+ content
+ .background(palette.surface.opacity(fillOpacity), in: shape)
+ }
+ }
+}
+
+extension View {
+ /// Applies Liquid Glass on macOS 26 while keeping the same semantic
+ /// surface colour on older systems.
+ func macGlassSurface(
+ 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: 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)
+ .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)
+ 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.. 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)
+
+ Label(
+ MacTranslationDisplay.label(for: viewModel.config.translationTargetLocaleId, language: lang),
+ systemImage: "translate"
+ )
+ .foregroundStyle(palette.textSecondary)
+
+ 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)
+ }
+}
diff --git a/OSGKeyboardMac/MacContentView.swift b/OSGKeyboardMac/MacContentView.swift
new file mode 100644
index 0000000..ebb3c51
--- /dev/null
+++ b/OSGKeyboardMac/MacContentView.swift
@@ -0,0 +1,145 @@
+// 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)
+
+ 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))
+ }
+
+ Divider().overlay(palette.divider)
+ statusRow
+ Divider().overlay(palette.divider)
+ footer
+ }
+ .padding(Spacing.md)
+ .background(palette.background)
+ }
+
+ 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)
+ }
+ }
+ }
+
+ private var recordButton: some View {
+ Button(action: viewModel.toggleRecording) {
+ HStack {
+ Image(systemName: viewModel.isRecording ? "stop.fill" : "mic.fill")
+ Text(
+ viewModel.isRecording
+ ? MacL10n.string("mac.record.stop", language: lang)
+ : MacL10n.string("mac.record.start", language: lang)
+ )
+ .font(TypeStyle.bodyEmph)
+ }
+ .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)
+ }
+
+ 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)
+ }
+ }
+}
diff --git a/OSGKeyboardMac/MacDictationPipeline.swift b/OSGKeyboardMac/MacDictationPipeline.swift
new file mode 100644
index 0000000..0125768
--- /dev/null
+++ b/OSGKeyboardMac/MacDictationPipeline.swift
@@ -0,0 +1,63 @@
+// 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
+
+ if store.engineMode == "local" {
+ raw = try await MacLocalASRService.transcribe(samples: samples, locale: locale)
+ } 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 }
+
+ if let polished = try? await PolishingService(store: store).polish(
+ trimmed,
+ mode: store.polishModeForPipeline
+ ),
+ !polished.isEmpty {
+ return polished
+ }
+ return trimmed
+ }
+}
diff --git a/OSGKeyboardMac/MacDictationViewModel.swift b/OSGKeyboardMac/MacDictationViewModel.swift
new file mode 100644
index 0000000..c801711
--- /dev/null
+++ b/OSGKeyboardMac/MacDictationViewModel.swift
@@ -0,0 +1,300 @@
+// 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()
+
+ 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()
+ warmUpQwen3IfNeeded()
+ }
+
+ /// Pre-load MLX weights + Metal shaders so the first dictation is fast.
+ func warmUpQwen3IfNeeded() {
+ guard config.engineMode == "local",
+ MacLocalASRPreferences.backend == .qwen3MLX,
+ MacLocalASRPreferences.qwen3ModelIsInstalled() else { return }
+ let path = MacLocalASRPreferences.qwen3ModelPath
+ Task.detached(priority: .utility) {
+ _ = try? await MacQwen3ASREngine.shared.prepareIfNeeded(modelPath: path)
+ }
+ }
+
+ func reloadConfigFromCloud() {
+ config.reloadFromPersistedStorage()
+ statusMessage = MacL10n.string("mac.status.ready", language: config.uiLanguage)
+ warmUpQwen3IfNeeded()
+ }
+
+ 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 qwen3ModelInstalled: Bool {
+ MacLocalASRPreferences.qwen3ModelIsInstalled()
+ }
+
+ // 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
+ if mode == "local" { warmUpQwen3IfNeeded() }
+ }
+
+ // 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()
+ }
+}
diff --git a/OSGKeyboardMac/MacDictionaryView.swift b/OSGKeyboardMac/MacDictionaryView.swift
new file mode 100644
index 0000000..f2f986b
--- /dev/null
+++ b/OSGKeyboardMac/MacDictionaryView.swift
@@ -0,0 +1,234 @@
+// 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
+ } else {
+ form
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(palette.background)
+ .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)
+ .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 { 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 {
+ 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(palette.textTertiary)
+ .opacity(isHovering ? 1 : 0)
+ .accessibilityLabel(MacL10n.string("mac.delete", language: language))
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(Rectangle())
+ .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")
+ }
+ }
+ }
+}
diff --git a/OSGKeyboardMac/MacHistoryView.swift b/OSGKeyboardMac/MacHistoryView.swift
new file mode 100644
index 0000000..f9edc2d
--- /dev/null
+++ b/OSGKeyboardMac/MacHistoryView.swift
@@ -0,0 +1,163 @@
+// 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
+ } else {
+ form
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(palette.background)
+ }
+
+ // 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) {
+ 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: { 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(palette.textTertiary)
+ .opacity(isHovering ? 1 : 0)
+ .accessibilityLabel(MacL10n.string("mac.delete", language: language))
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(Rectangle())
+ .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")
+ }
+ }
+ }
+}
diff --git a/OSGKeyboardMac/MacHotkeyService.swift b/OSGKeyboardMac/MacHotkeyService.swift
new file mode 100644
index 0000000..304c201
--- /dev/null
+++ b/OSGKeyboardMac/MacHotkeyService.swift
@@ -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?()
+ }
+ }
+}
diff --git a/OSGKeyboardMac/MacICloudSyncBootstrap.swift b/OSGKeyboardMac/MacICloudSyncBootstrap.swift
new file mode 100644
index 0000000..ca028b8
--- /dev/null
+++ b/OSGKeyboardMac/MacICloudSyncBootstrap.swift
@@ -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
+ }
+}
diff --git a/OSGKeyboardMac/MacICloudSyncRows.swift b/OSGKeyboardMac/MacICloudSyncRows.swift
new file mode 100644
index 0000000..c6057dd
--- /dev/null
+++ b/OSGKeyboardMac/MacICloudSyncRows.swift
@@ -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 {
+ 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 {
+ 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
+ }
+}
diff --git a/OSGKeyboardMac/MacL10n.swift b/OSGKeyboardMac/MacL10n.swift
new file mode 100644
index 0000000..aca291e
--- /dev/null
+++ b/OSGKeyboardMac/MacL10n.swift
@@ -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)
+ }
+}
diff --git a/OSGKeyboardMac/MacLocalASRService.swift b/OSGKeyboardMac/MacLocalASRService.swift
new file mode 100644
index 0000000..0f7f3e9
--- /dev/null
+++ b/OSGKeyboardMac/MacLocalASRService.swift
@@ -0,0 +1,103 @@
+// MacLocalASRService.swift
+// OSGKeyboard · Mac
+//
+// On-device ASR for macOS. Primary: Qwen3-ASR-1.7B (MLX via mlx-swift-asr).
+// Falls back to Apple Speech when Qwen3 weights are absent or backend is Apple Speech.
+
+import Foundation
+
+enum MacLocalASRBackend: String, Sendable, CaseIterable {
+ case qwen3MLX
+ 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 qwen3ModelPathKey = "mac.localASR.qwen3ModelPath"
+
+ static var backend: MacLocalASRBackend {
+ get {
+ guard let raw = UserDefaults.standard.string(forKey: backendKey),
+ let value = MacLocalASRBackend(rawValue: raw) else {
+ return .qwen3MLX
+ }
+ return value
+ }
+ set { UserDefaults.standard.set(newValue.rawValue, forKey: backendKey) }
+ }
+
+ static var qwen3ModelPath: String {
+ get { UserDefaults.standard.string(forKey: qwen3ModelPathKey) ?? defaultQwen3ModelPath }
+ set { UserDefaults.standard.set(newValue, forKey: qwen3ModelPathKey) }
+ }
+
+ /// Default install location for MLX-converted Qwen3-ASR weights.
+ static var defaultQwen3ModelPath: String {
+ let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
+ return appSupport.appendingPathComponent("OSGKeyboard/models/qwen3-asr-1.7b-mlx", isDirectory: true).path
+ }
+
+ static func qwen3ModelIsInstalled(at path: String = qwen3ModelPath) -> 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")
+ }
+}
+
+enum MacLocalASRService {
+ /// Transcribe using the user's preferred local backend with automatic
+ /// fallback to Apple Speech when Qwen3 weights are not present.
+ static func transcribe(samples: [Float], locale: Locale) async throws -> String {
+ let preferQwen3 = MacLocalASRPreferences.backend == .qwen3MLX
+ if preferQwen3, MacLocalASRPreferences.qwen3ModelIsInstalled() {
+ do {
+ return try await MacQwen3LocalASR.transcribe(
+ samples: samples,
+ sampleRate: 16_000,
+ locale: locale,
+ modelPath: MacLocalASRPreferences.qwen3ModelPath
+ )
+ } catch MacLocalASRError.qwen3ModelMissing {
+ // Fall through to Apple Speech when weights are absent.
+ } catch {
+ throw error
+ }
+ }
+ return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale)
+ }
+}
diff --git a/OSGKeyboardMac/MacQwen3ASREngine.swift b/OSGKeyboardMac/MacQwen3ASREngine.swift
new file mode 100644
index 0000000..a2eee4b
--- /dev/null
+++ b/OSGKeyboardMac/MacQwen3ASREngine.swift
@@ -0,0 +1,94 @@
+// 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
+ ) 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)
+ 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
+ }
+}
diff --git a/OSGKeyboardMac/MacQwen3LocalASR.swift b/OSGKeyboardMac/MacQwen3LocalASR.swift
new file mode 100644
index 0000000..377609e
--- /dev/null
+++ b/OSGKeyboardMac/MacQwen3LocalASR.swift
@@ -0,0 +1,39 @@
+// 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
+ ) async throws -> String {
+ guard MacLocalASRPreferences.qwen3ModelIsInstalled(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)
+ do {
+ return try await MacQwen3ASREngine.shared.transcribe(
+ samples: samples,
+ language: language,
+ modelPath: modelPath
+ )
+ } catch let error as MacLocalASRError {
+ throw error
+ } catch {
+ throw MacLocalASRError.qwen3InferenceFailed(error.localizedDescription)
+ }
+ }
+}
diff --git a/OSGKeyboardMac/MacRootView.swift b/OSGKeyboardMac/MacRootView.swift
new file mode 100644
index 0000000..dc904e4
--- /dev/null
+++ b/OSGKeyboardMac/MacRootView.swift
@@ -0,0 +1,131 @@
+// 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 {
+ 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
+ sidebarRow(section)
+ }
+ }
+ .padding(.horizontal, MacMetrics.sidebarInset)
+ Spacer()
+ devicesFooter
+ }
+ .background(palette.surfaceMuted)
+ }
+
+ private func sidebarRow(_ section: MacSection) -> some View {
+ let isSelected = viewModel.selectedSection == section
+
+ return Button {
+ viewModel.selectedSection = section
+ } label: {
+ Label(section.title(language: uiLanguage), 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(
+ isSelected ? palette.accent : Color.clear,
+ in: RoundedRectangle(cornerRadius: 7, style: .continuous)
+ )
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ }
+
+ /// 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)
+ MacStatusFooter(viewModel: viewModel)
+ }
+ .background(palette.background)
+ }
+}
diff --git a/OSGKeyboardMac/MacSettingsView.swift b/OSGKeyboardMac/MacSettingsView.swift
new file mode 100644
index 0000000..6648178
--- /dev/null
+++ b/OSGKeyboardMac/MacSettingsView.swift
@@ -0,0 +1,431 @@
+// 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
+ @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 {
+ Form {
+ generalSection
+ recognitionSection
+ if viewModel.config.engineMode == "cloud" {
+ providerSection
+ }
+ if viewModel.config.engineMode == "local" {
+ qwen3Section
+ }
+ inputSection
+ syncSection
+ }
+ .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)
+ }
+ }
+ }
+ }
+
+ // MARK: - iCloud
+
+ private var syncSection: some View {
+ Section("iCloud") {
+ 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"
+ ) { 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"
+ ) { viewModel.setEngineMode("local") }
+
+ if viewModel.config.engineMode == "local", !viewModel.qwen3ModelInstalled {
+ Label(MacL10n.string("mac.settings.qwen3Missing", language: lang), systemImage: "exclamationmark.triangle")
+ .font(TypeStyle.caption)
+ .foregroundStyle(palette.warning)
+ }
+ }
+ }
+
+ // 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)
+
+ 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: - Qwen3 model path
+
+ private var qwen3Section: some View {
+ Section {
+ HStack(spacing: Spacing.sm) {
+ TextField("", text: qwen3PathBinding, prompt: Text(verbatim: "~/Models/Qwen3-ASR"))
+ .macFieldStyle()
+ Button(MacL10n.string("mac.settings.qwen3Browse", language: lang)) {
+ pickQwen3Folder()
+ }
+ }
+ } header: {
+ Text(MacL10n.string("mac.settings.qwen3Model", language: lang))
+ } footer: {
+ Text(MacL10n.string("mac.settings.qwen3ModelDesc", language: lang))
+ .font(TypeStyle.caption)
+ .foregroundStyle(palette.textSecondary)
+ }
+ }
+
+ // 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)
+ }
+ .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 {
+ Binding(
+ get: { viewModel.config.uiLanguage.rawValue },
+ set: { viewModel.config.uiLanguage = AppUILanguage(rawValue: $0) ?? .auto }
+ )
+ }
+
+ private var providerBinding: Binding {
+ 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 {
+ 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 {
+ Binding(
+ get: { viewModel.hotkeyEnabled },
+ set: { viewModel.setHotkeyEnabled($0) }
+ )
+ }
+
+ private var autoPasteBinding: Binding {
+ Binding(
+ get: { viewModel.autoPasteEnabled },
+ set: { viewModel.setAutoPasteEnabled($0) }
+ )
+ }
+
+ private var qwen3PathBinding: Binding {
+ Binding(
+ get: { MacLocalASRPreferences.qwen3ModelPath },
+ set: { newPath in
+ MacLocalASRPreferences.qwen3ModelPath = newPath
+ Task { await MacQwen3ASREngine.shared.unload() }
+ viewModel.warmUpQwen3IfNeeded()
+ }
+ )
+ }
+
+ // 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"
+ }
+
+ private func pickQwen3Folder() {
+ #if os(macOS)
+ let panel = NSOpenPanel()
+ panel.canChooseDirectories = true
+ panel.canChooseFiles = false
+ panel.allowsMultipleSelection = false
+ panel.begin { response in
+ guard response == .OK, let url = panel.url else { return }
+ MacLocalASRPreferences.qwen3ModelPath = url.path
+ Task { await MacQwen3ASREngine.shared.unload() }
+ viewModel.warmUpQwen3IfNeeded()
+ }
+ #endif
+ }
+}
diff --git a/OSGKeyboardMac/MacSpeechLocalASR.swift b/OSGKeyboardMac/MacSpeechLocalASR.swift
new file mode 100644
index 0000000..b30f5e3
--- /dev/null
+++ b/OSGKeyboardMac/MacSpeechLocalASR.swift
@@ -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
+ }
+}
diff --git a/OSGKeyboardMac/MacTextInsertionService.swift b/OSGKeyboardMac/MacTextInsertionService.swift
new file mode 100644
index 0000000..e8d837d
--- /dev/null
+++ b/OSGKeyboardMac/MacTextInsertionService.swift
@@ -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)
+ }
+}
diff --git a/OSGKeyboardMac/MacTheme.swift b/OSGKeyboardMac/MacTheme.swift
new file mode 100644
index 0000000..9728001
--- /dev/null
+++ b/OSGKeyboardMac/MacTheme.swift
@@ -0,0 +1,49 @@
+// 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 (`Color(nsColor:)`), which adapt to light / dark on their
+// own. The brand green is kept only as the accent. This gives the app the
+// same zero-colour-difference, System-Settings / Notes look on both
+// appearances while reusing every existing `palette.X` call site.
+
+import AppKit
+import SwiftUI
+
+enum MacSystemPalette {
+ /// A `ThemePalette` whose surfaces and text resolve to AppKit semantic
+ /// colours. Because those colours are dynamic, a single value renders
+ /// correctly under both light and dark (driven by `preferredColorScheme`).
+ static let palette = ThemePalette(
+ background: Color(nsColor: .windowBackgroundColor),
+ surface: Color(nsColor: .controlBackgroundColor),
+ surfaceElevated: Color(nsColor: .unemphasizedSelectedContentBackgroundColor),
+ surfaceMuted: Color(nsColor: .underPageBackgroundColor),
+
+ accent: Palette.accent,
+ accentMuted: Palette.accent.opacity(0.16),
+ accentGlow: Palette.accent.opacity(0.35),
+
+ danger: Color(nsColor: .systemRed),
+ success: Palette.accent,
+ warning: Color(nsColor: .systemOrange),
+
+ textPrimary: Color(nsColor: .labelColor),
+ textSecondary: Color(nsColor: .secondaryLabelColor),
+ textTertiary: Color(nsColor: .tertiaryLabelColor),
+ textOnAccent: Color.white,
+
+ divider: Color(nsColor: .separatorColor),
+ dividerStrong: Color(nsColor: .separatorColor),
+
+ recordRed: Color(nsColor: .systemRed)
+ )
+}
+
+extension View {
+ /// Injects the system-native palette used across the macOS app.
+ func macSystemPalette() -> some View {
+ environment(\.themePalette, MacSystemPalette.palette)
+ }
+}
diff --git a/OSGKeyboardMac/OSGKeyboardMac.entitlements b/OSGKeyboardMac/OSGKeyboardMac.entitlements
new file mode 100644
index 0000000..422ac05
--- /dev/null
+++ b/OSGKeyboardMac/OSGKeyboardMac.entitlements
@@ -0,0 +1,18 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.device.audio-input
+
+ com.apple.security.network.client
+
+ com.apple.developer.ubiquity-kvstore-identifier
+ $(TeamIdentifierPrefix)com.osgkeyboard.ios
+ keychain-access-groups
+
+ $(AppIdentifierPrefix)com.osgkeyboard.shared
+
+
+
diff --git a/OSGKeyboardMac/OSGKeyboardMacApp.swift b/OSGKeyboardMac/OSGKeyboardMacApp.swift
new file mode 100644
index 0000000..0895bf8
--- /dev/null
+++ b/OSGKeyboardMac/OSGKeyboardMacApp.swift
@@ -0,0 +1,145 @@
+// 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
+
+ private var appearance: MacAppearancePreference {
+ MacAppearancePreference(rawValue: appearanceRaw) ?? .system
+ }
+
+ var body: some Scene {
+ Window("OSGKeyboard", id: "main") {
+ MacRootView(viewModel: viewModel)
+ .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()
+ }
+
+ /// 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 {
+ // Prefer the brand mark; fall back to an SF Symbol so the item is
+ // never invisible even if the asset fails to resolve.
+ let image = NSImage(named: "OSGBrandMark")
+ ?? NSImage(systemSymbolName: "mic.circle.fill", accessibilityDescription: "OSGKeyboard")
+ image?.isTemplate = true
+ image?.size = NSSize(width: 18, height: 18)
+ button.image = image
+ button.image?.accessibilityDescription = "OSGKeyboard"
+ button.action = #selector(togglePopover(_:))
+ button.target = self
+ }
+ statusItem = item
+ }
+
+ 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
+
+ var body: some View {
+ MacContentView(viewModel: viewModel)
+ .frame(width: 340)
+ .macSystemPalette()
+ .environment(\.locale, viewModel.config.uiLanguage.swiftUILocale)
+ .preferredColorScheme(MacAppearancePreference(rawValue: appearanceRaw)?.colorScheme ?? nil)
+ }
+}
diff --git a/OSGKeyboardShared/Core/Configuration/AppGroupStore+ConfigurationStore.swift b/OSGKeyboardShared/Core/Configuration/AppGroupStore+ConfigurationStore.swift
new file mode 100644
index 0000000..ab2700d
--- /dev/null
+++ b/OSGKeyboardShared/Core/Configuration/AppGroupStore+ConfigurationStore.swift
@@ -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 }
+}
diff --git a/OSGKeyboardShared/Core/Configuration/ConfigurationStore.swift b/OSGKeyboardShared/Core/Configuration/ConfigurationStore.swift
new file mode 100644
index 0000000..1f81e24
--- /dev/null
+++ b/OSGKeyboardShared/Core/Configuration/ConfigurationStore.swift
@@ -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
+}
diff --git a/OSGKeyboardShared/DesignSystem/RecordButton.swift b/OSGKeyboardShared/DesignSystem/RecordButton.swift
index ad55469..4dd3e83 100644
--- a/OSGKeyboardShared/DesignSystem/RecordButton.swift
+++ b/OSGKeyboardShared/DesignSystem/RecordButton.swift
@@ -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,
diff --git a/OSGKeyboardShared/Models/AppGroupConfiguration.swift b/OSGKeyboardShared/Models/AppGroupConfiguration.swift
index 6a1b802..cb656f2 100644
--- a/OSGKeyboardShared/Models/AppGroupConfiguration.swift
+++ b/OSGKeyboardShared/Models/AppGroupConfiguration.swift
@@ -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
}
diff --git a/OSGKeyboardShared/Models/MicVoiceAvailability+Keyboard.swift b/OSGKeyboardShared/Models/MicVoiceAvailability+Keyboard.swift
new file mode 100644
index 0000000..d81df56
--- /dev/null
+++ b/OSGKeyboardShared/Models/MicVoiceAvailability+Keyboard.swift
@@ -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)
+ }
+}
diff --git a/OSGKeyboardShared/Models/MicVoiceAvailability.swift b/OSGKeyboardShared/Models/MicVoiceAvailability.swift
new file mode 100644
index 0000000..4760070
--- /dev/null
+++ b/OSGKeyboardShared/Models/MicVoiceAvailability.swift
@@ -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
+ }
+}
diff --git a/OSGKeyboardShared/Models/PersonalDictionary+Merging.swift b/OSGKeyboardShared/Models/PersonalDictionary+Merging.swift
index f6d558a..14318fb 100644
--- a/OSGKeyboardShared/Models/PersonalDictionary+Merging.swift
+++ b/OSGKeyboardShared/Models/PersonalDictionary+Merging.swift
@@ -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
}
diff --git a/OSGKeyboardShared/Models/PersonalDictionary.swift b/OSGKeyboardShared/Models/PersonalDictionary.swift
index 1df5614..6e91811 100644
--- a/OSGKeyboardShared/Models/PersonalDictionary.swift
+++ b/OSGKeyboardShared/Models/PersonalDictionary.swift
@@ -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 {
diff --git a/OSGKeyboardShared/Models/ProviderConfig.swift b/OSGKeyboardShared/Models/ProviderConfig.swift
index 07252f4..2259dc3 100644
--- a/OSGKeyboardShared/Models/ProviderConfig.swift
+++ b/OSGKeyboardShared/Models/ProviderConfig.swift
@@ -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)")
}
diff --git a/OSGKeyboardShared/Models/ProviderLogo.swift b/OSGKeyboardShared/Models/ProviderLogo.swift
new file mode 100644
index 0000000..1723e58
--- /dev/null
+++ b/OSGKeyboardShared/Models/ProviderLogo.swift
@@ -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
+ }
+ }
+}
diff --git a/OSGKeyboardShared/Models/SpeechHistoryEntry.swift b/OSGKeyboardShared/Models/SpeechHistoryEntry.swift
new file mode 100644
index 0000000..feccc20
--- /dev/null
+++ b/OSGKeyboardShared/Models/SpeechHistoryEntry.swift
@@ -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
+ }
+}
diff --git a/OSGKeyboardShared/Models/SyncedAppSettings.swift b/OSGKeyboardShared/Models/SyncedAppSettings.swift
index aa5a8cd..07d46ca 100644
--- a/OSGKeyboardShared/Models/SyncedAppSettings.swift
+++ b/OSGKeyboardShared/Models/SyncedAppSettings.swift
@@ -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
- }
}
diff --git a/OSGKeyboardShared/Models/SyncedAppSettingsV2.swift b/OSGKeyboardShared/Models/SyncedAppSettingsV2.swift
new file mode 100644
index 0000000..131e6f9
--- /dev/null
+++ b/OSGKeyboardShared/Models/SyncedAppSettingsV2.swift
@@ -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
+ public var baseURL: SyncedField
+ public var model: SyncedField
+ public var modeId: SyncedField
+ public var localeId: SyncedField
+ public var engineMode: SyncedField
+ public var hasAcknowledgedCloudSharing: SyncedField
+ public var uiLanguage: SyncedField
+ public var translationTargetLocaleId: SyncedField
+ public var handednessPreference: SyncedField
+ public var cursorDragNavigationEnabled: SyncedField
+ public var polishIntensity: SyncedField
+ public var flowSkipAppSwitch: SyncedField
+ public var flowInactivityDuration: SyncedField
+
+ public init(
+ schemaVersion: Int = Self.schemaVersion,
+ providerId: SyncedField,
+ baseURL: SyncedField,
+ model: SyncedField,
+ modeId: SyncedField,
+ localeId: SyncedField,
+ engineMode: SyncedField,
+ hasAcknowledgedCloudSharing: SyncedField,
+ uiLanguage: SyncedField,
+ translationTargetLocaleId: SyncedField,
+ handednessPreference: SyncedField,
+ cursorDragNavigationEnabled: SyncedField,
+ polishIntensity: SyncedField,
+ flowSkipAppSwitch: SyncedField,
+ flowInactivityDuration: SyncedField
+ ) {
+ 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(_ value: T) -> SyncedField {
+ 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(_ value: T) -> SyncedField {
+ 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(_ field: inout SyncedField, 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(_ field: inout SyncedField, 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
+ }
+}
diff --git a/OSGKeyboardShared/Models/SyncedField.swift b/OSGKeyboardShared/Models/SyncedField.swift
new file mode 100644
index 0000000..d422184
--- /dev/null
+++ b/OSGKeyboardShared/Models/SyncedField.swift
@@ -0,0 +1,29 @@
+// SyncedField.swift
+// OSGKeyboard · Shared
+//
+// Per-field metadata for conflict-free settings merge across devices.
+
+import Foundation
+
+public struct SyncedField: 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, remote: SyncedField) -> SyncedField {
+ 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 {
+ SyncedField(value: value, updatedAt: Date(), deviceID: deviceID)
+ }
+}
diff --git a/OSGKeyboardShared/Models/SyncedSpeechHistory.swift b/OSGKeyboardShared/Models/SyncedSpeechHistory.swift
new file mode 100644
index 0000000..5cd5a12
--- /dev/null
+++ b/OSGKeyboardShared/Models/SyncedSpeechHistory.swift
@@ -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
+ }
+}
diff --git a/OSGKeyboardShared/Models/SyncedUsageStatisticsV2.swift b/OSGKeyboardShared/Models/SyncedUsageStatisticsV2.swift
new file mode 100644
index 0000000..a10937b
--- /dev/null
+++ b/OSGKeyboardShared/Models/SyncedUsageStatisticsV2.swift
@@ -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)
+ }
+}
diff --git a/OSGKeyboardShared/Models/UsageStatistics.swift b/OSGKeyboardShared/Models/UsageStatistics.swift
new file mode 100644
index 0000000..f635002
--- /dev/null
+++ b/OSGKeyboardShared/Models/UsageStatistics.swift
@@ -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
+ }
+}
diff --git a/OSGKeyboardShared/Services/ASRService.swift b/OSGKeyboardShared/Services/ASRService.swift
index 0f068ae..4fe3015 100644
--- a/OSGKeyboardShared/Services/ASRService.swift
+++ b/OSGKeyboardShared/Services/ASRService.swift
@@ -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)
}
diff --git a/OSGKeyboardShared/Services/AppGroupStore.swift b/OSGKeyboardShared/Services/AppGroupStore.swift
index e9de78d..f1e7bdf 100644
--- a/OSGKeyboardShared/Services/AppGroupStore.swift
+++ b/OSGKeyboardShared/Services/AppGroupStore.swift
@@ -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 {
@@ -165,6 +175,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) }
diff --git a/OSGKeyboardShared/Services/CloudASR/CloudASRClients.swift b/OSGKeyboardShared/Services/CloudASR/CloudASRClients.swift
index 4151198..36252f9 100644
--- a/OSGKeyboardShared/Services/CloudASR/CloudASRClients.swift
+++ b/OSGKeyboardShared/Services/CloudASR/CloudASRClients.swift
@@ -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
)
diff --git a/OSGKeyboardShared/Services/CloudASR/CloudASRService.swift b/OSGKeyboardShared/Services/CloudASR/CloudASRService.swift
index 151e39f..59a5171 100644
--- a/OSGKeyboardShared/Services/CloudASR/CloudASRService.swift
+++ b/OSGKeyboardShared/Services/CloudASR/CloudASRService.swift
@@ -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
) {
diff --git a/OSGKeyboardShared/Services/FlowContinuousCapture.swift b/OSGKeyboardShared/Services/FlowContinuousCapture.swift
index 78db702..9e7e4d9 100644
--- a/OSGKeyboardShared/Services/FlowContinuousCapture.swift
+++ b/OSGKeyboardShared/Services/FlowContinuousCapture.swift
@@ -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,6 +379,7 @@ public final class FlowContinuousCapture {
false,
options: .notifyOthersOnDeactivation
)
+ notifyEngineLiveChanged()
}
/// Re-activate capture after returning from background without
@@ -355,6 +396,21 @@ public final class FlowContinuousCapture {
if !audioEngine.isRunning {
try? audioEngine.start()
}
+ notifyEngineLiveChanged()
+ }
+
+ 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
@@ -434,6 +490,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 +519,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 {
let (stream, continuation) = AsyncStream.makeStream()
@@ -546,6 +609,7 @@ public final class FlowContinuousCapture {
downsampler: AdaptiveDownsampler,
gate: OSAllocatedUnfairLock,
levelStore: FlowLevelStore,
+ audioProofStore: FlowAudioProofStore,
prerollStore: FlowPrerollStore,
streamRelay: FlowCaptureStreamRelay,
drainTracker: FlowCaptureDrainTracker,
@@ -553,6 +617,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
diff --git a/OSGKeyboardShared/Services/FlowSessionBridge.swift b/OSGKeyboardShared/Services/FlowSessionBridge.swift
index 4c54d1d..e963cda 100644
--- a/OSGKeyboardShared/Services/FlowSessionBridge.swift
+++ b/OSGKeyboardShared/Services/FlowSessionBridge.swift
@@ -36,6 +36,15 @@ public enum FlowSessionBridge {
}
}
+ /// 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: - Session lifecycle (host app)
public static func markSessionActive(
@@ -62,12 +71,17 @@ public enum FlowSessionBridge {
store.removeObject(forKey: FlowSessionKeys.flowHeartbeat)
setRecordingState(.idle, defaults: store)
clearTranscription(defaults: store)
+ 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 +132,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 +150,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 +159,44 @@ 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)
+ 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(
@@ -346,6 +397,7 @@ public enum FlowSessionBridge {
store.removeObject(forKey: FlowSessionKeys.audioLevels)
store.removeObject(forKey: FlowSessionKeys.pendingHostBundleId)
store.removeObject(forKey: FlowSessionKeys.lastActivityAt)
+ clearHostReady(defaults: store, notify: false)
flush(store)
}
diff --git a/OSGKeyboardShared/Services/FlowSessionDarwin.swift b/OSGKeyboardShared/Services/FlowSessionDarwin.swift
index c54676e..4c5d942 100644
--- a/OSGKeyboardShared/Services/FlowSessionDarwin.swift
+++ b/OSGKeyboardShared/Services/FlowSessionDarwin.swift
@@ -10,6 +10,8 @@ public enum FlowSessionDarwin {
public static let notificationName = "com.osgkeyboard.flow.session.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(
@@ -30,6 +32,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
diff --git a/OSGKeyboardShared/Services/FlowSessionKeys.swift b/OSGKeyboardShared/Services/FlowSessionKeys.swift
index 7ea238e..6d4403a 100644
--- a/OSGKeyboardShared/Services/FlowSessionKeys.swift
+++ b/OSGKeyboardShared/Services/FlowSessionKeys.swift
@@ -10,6 +10,10 @@ public enum FlowSessionKeys {
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 +33,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
diff --git a/OSGKeyboardShared/Services/ICloudSync/AppCloudSync.swift b/OSGKeyboardShared/Services/ICloudSync/AppCloudSync.swift
index a186f84..a45efd6 100644
--- a/OSGKeyboardShared/Services/ICloudSync/AppCloudSync.swift
+++ b/OSGKeyboardShared/Services/ICloudSync/AppCloudSync.swift
@@ -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 }
}
diff --git a/OSGKeyboardShared/Services/ICloudSync/CloudSyncContext.swift b/OSGKeyboardShared/Services/ICloudSync/CloudSyncContext.swift
new file mode 100644
index 0000000..2a38a27
--- /dev/null
+++ b/OSGKeyboardShared/Services/ICloudSync/CloudSyncContext.swift
@@ -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
+ }
+}
diff --git a/OSGKeyboardShared/Services/ICloudSync/SettingsCloudSync.swift b/OSGKeyboardShared/Services/ICloudSync/SettingsCloudSync.swift
index 4d1e8d6..5a5883c 100644
--- a/OSGKeyboardShared/Services/ICloudSync/SettingsCloudSync.swift
+++ b/OSGKeyboardShared/Services/ICloudSync/SettingsCloudSync.swift
@@ -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
+ )
}
}
diff --git a/OSGKeyboardShared/Services/ICloudSync/SpeechHistoryCloudSync.swift b/OSGKeyboardShared/Services/ICloudSync/SpeechHistoryCloudSync.swift
new file mode 100644
index 0000000..676146a
--- /dev/null
+++ b/OSGKeyboardShared/Services/ICloudSync/SpeechHistoryCloudSync.swift
@@ -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)
+ }
+ }
+}
diff --git a/OSGKeyboardShared/Services/ICloudSync/SyncDeviceID.swift b/OSGKeyboardShared/Services/ICloudSync/SyncDeviceID.swift
new file mode 100644
index 0000000..7be64d5
--- /dev/null
+++ b/OSGKeyboardShared/Services/ICloudSync/SyncDeviceID.swift
@@ -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
+ }
+}
diff --git a/OSGKeyboardShared/Services/ICloudSync/UsageStatisticsCloudSync.swift b/OSGKeyboardShared/Services/ICloudSync/UsageStatisticsCloudSync.swift
new file mode 100644
index 0000000..b0773a5
--- /dev/null
+++ b/OSGKeyboardShared/Services/ICloudSync/UsageStatisticsCloudSync.swift
@@ -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)
+ }
+ }
+}
diff --git a/OSGKeyboardShared/Services/KeyboardState.swift b/OSGKeyboardShared/Services/KeyboardState.swift
index 0a21b78..49c13bf 100644
--- a/OSGKeyboardShared/Services/KeyboardState.swift
+++ b/OSGKeyboardShared/Services/KeyboardState.swift
@@ -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
diff --git a/OSGKeyboardShared/Services/Keychain.swift b/OSGKeyboardShared/Services/Keychain.swift
index 3d6b670..9d0b7f2 100644
--- a/OSGKeyboardShared/Services/Keychain.swift
+++ b/OSGKeyboardShared/Services/Keychain.swift
@@ -1,33 +1,13 @@
// Keychain.swift
// OSGKeyboard · Shared
//
-// Single-purpose Keychain helper for the user's LLM API key.
+// Keychain helper for LLM API keys and onboarding markers.
//
-// Why this exists
-// ---------------
-// Both the host app and the keyboard extension need to read the same API
-// key (the host writes it in Settings; the extension uses it to
-// authenticate LLM requests). Storing it in App Group `UserDefaults` is
-// plaintext on disk and shows up in any unencrypted backup. The Keychain
-// gives us at-rest encryption and proper lifecycle.
-//
-// Cross-process sharing
-// ---------------------
-// App and extension have different bundle IDs, so their default Keychain
-// access groups differ and they cannot see each other's items out of the
-// box. We add `com.apple.security.keychain-access-groups` to both
-// targets' entitlements with the entry `com.osgkeyboard.shared`; this
-// becomes each process's *first* (and therefore default) access group, so
-// we never need to specify `kSecAttrAccessGroup` in queries — the system
-// resolves it for us.
-//
-// Accessibility class
-// -------------------
-// `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`:
-// - Available after the user unlocks the device at least once after
-// boot (so background jobs work even with a locked phone).
-// - "ThisDeviceOnly" — does not migrate to a restored device and is
-// NOT included in iCloud Keychain. API keys should not sync.
+// API keys:
+// - Local (device-only) items use `AfterFirstUnlockThisDeviceOnly`.
+// - When settings iCloud sync is enabled, keys are stored as synchronizable
+// generic passwords (`kSecAttrSynchronizable = true`) and replicate through
+// the user's iCloud Keychain — never through KVS JSON.
import Foundation
import Security
@@ -48,19 +28,42 @@ public enum Keychain: @unchecked Sendable {
return "provider.\(normalized)"
}
- // MARK: - Read
-
- /// Read the stored API key. Returns `nil` when nothing is stored,
- /// or when the underlying call returns a non-success status we can't
- /// usefully surface (e.g. transient `errSecInteractionNotAllowed`).
- public static func apiKey(for providerId: String) -> String? {
- let query: [String: Any] = [
+ private static func baseQuery(providerId: String, synchronizable: Bool) -> [String: Any] {
+ var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account(for: providerId),
- kSecReturnData as String: true,
- kSecMatchLimit as String: kSecMatchLimitOne,
+ kSecAttrSynchronizable as String: synchronizable ? kCFBooleanTrue! : kCFBooleanFalse!,
]
+ #if os(macOS)
+ query[kSecUseDataProtectionKeychain as String] = true
+ #endif
+ return query
+ }
+
+ // MARK: - Read
+
+ public static func apiKey(for providerId: String, preferICloudSync: Bool = false) -> String? {
+ if preferICloudSync, let synced = readKey(providerId: providerId, synchronizable: true) {
+ return synced
+ }
+ if let local = readKey(providerId: providerId, synchronizable: false) {
+ return local
+ }
+ if preferICloudSync {
+ return readKey(providerId: providerId, synchronizable: true)
+ }
+ return nil
+ }
+
+ public static func apiKey() -> String? {
+ apiKey(for: defaultProviderId)
+ }
+
+ private static func readKey(providerId: String, synchronizable: Bool) -> String? {
+ var query = baseQuery(providerId: providerId, synchronizable: synchronizable)
+ query[kSecReturnData as String] = true
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
switch status {
@@ -80,21 +83,17 @@ public enum Keychain: @unchecked Sendable {
}
}
- /// Backward-compatible shorthand for the default cloud provider.
- public static func apiKey() -> String? {
- apiKey(for: defaultProviderId)
- }
-
- /// Legacy account used by older builds before provider-scoped keys.
- /// New code should avoid this and use `apiKey(for:)`.
public static func legacyAPIKey() -> String? {
- let query: [String: Any] = [
+ var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: legacyAccount,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
+ #if os(macOS)
+ query[kSecUseDataProtectionKeychain as String] = true
+ #endif
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
@@ -106,35 +105,37 @@ public enum Keychain: @unchecked Sendable {
// MARK: - Write
- /// Store (or update) the API key. An empty string deletes the entry,
- /// so clearing the field in the UI removes the key from the Keychain
- /// rather than leaving an empty-string placeholder.
- public static func setAPIKey(_ key: String, for providerId: String) throws {
+ public static func setAPIKey(_ key: String, for providerId: String, useICloudSync: Bool = false) throws {
if key.isEmpty {
- try deleteAPIKey(for: providerId)
+ try deleteAPIKey(for: providerId, useICloudSync: useICloudSync)
return
}
+ if useICloudSync {
+ try writeKey(key, providerId: providerId, synchronizable: true)
+ try? deleteKey(providerId: providerId, synchronizable: false)
+ } else {
+ try writeKey(key, providerId: providerId, synchronizable: false)
+ }
+ }
+
+ public static func setAPIKey(_ key: String) throws {
+ try setAPIKey(key, for: defaultProviderId, useICloudSync: false)
+ }
+
+ private static func writeKey(_ key: String, providerId: String, synchronizable: Bool) throws {
let data = Data(key.utf8)
- let baseQuery: [String: Any] = [
- kSecClass as String: kSecClassGenericPassword,
- kSecAttrService as String: service,
- kSecAttrAccount as String: account(for: providerId),
- ]
- // Try update first — covers the common path where the key already
- // exists (every settings edit after the first).
- let updateAttrs: [String: Any] = [
- kSecValueData as String: data,
- ]
+ var baseQuery = baseQuery(providerId: providerId, synchronizable: synchronizable)
+ let updateAttrs: [String: Any] = [kSecValueData as String: data]
let updateStatus = SecItemUpdate(baseQuery as CFDictionary, updateAttrs as CFDictionary)
switch updateStatus {
case errSecSuccess:
return
case errSecItemNotFound:
- // No existing item — add one with our accessibility class.
- var addQuery = baseQuery
- addQuery[kSecValueData as String] = data
- addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
- let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
+ baseQuery[kSecValueData as String] = data
+ baseQuery[kSecAttrAccessible as String] = synchronizable
+ ? kSecAttrAccessibleAfterFirstUnlock
+ : kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
+ let addStatus = SecItemAdd(baseQuery as CFDictionary, nil)
if addStatus != errSecSuccess {
throw KeychainError.unexpectedStatus(addStatus)
}
@@ -143,64 +144,73 @@ public enum Keychain: @unchecked Sendable {
}
}
- /// Backward-compatible shorthand for the default cloud provider.
- public static func setAPIKey(_ key: String) throws {
- try setAPIKey(key, for: defaultProviderId)
- }
-
// MARK: - Delete
+ public static func deleteAPIKey(for providerId: String, useICloudSync: Bool = false) throws {
+ try deleteKey(providerId: providerId, synchronizable: false)
+ if useICloudSync {
+ try deleteKey(providerId: providerId, synchronizable: true)
+ }
+ }
+
public static func deleteAPIKey(for providerId: String) throws {
- let query: [String: Any] = [
- kSecClass as String: kSecClassGenericPassword,
- kSecAttrService as String: service,
- kSecAttrAccount as String: account(for: providerId),
- ]
+ try deleteAPIKey(for: providerId, useICloudSync: false)
+ }
+
+ public static func deleteAPIKey() throws {
+ try deleteAPIKey(for: defaultProviderId)
+ }
+
+ private static func deleteKey(providerId: String, synchronizable: Bool) throws {
+ let query = baseQuery(providerId: providerId, synchronizable: synchronizable)
let status = SecItemDelete(query as CFDictionary)
- // `errSecItemNotFound` is success-from-the-user's-perspective — the
- // desired end state is "no key", which is what we already have.
if status != errSecSuccess && status != errSecItemNotFound {
throw KeychainError.unexpectedStatus(status)
}
}
- /// Backward-compatible shorthand for the default cloud provider.
- public static func deleteAPIKey() throws {
- try deleteAPIKey(for: defaultProviderId)
- }
-
public static func deleteLegacyAPIKey() throws {
- let query: [String: Any] = [
+ var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: legacyAccount,
]
+ #if os(macOS)
+ query[kSecUseDataProtectionKeychain as String] = true
+ #endif
let status = SecItemDelete(query as CFDictionary)
if status != errSecSuccess && status != errSecItemNotFound {
throw KeychainError.unexpectedStatus(status)
}
}
+ /// Copy non-empty local keys into synchronizable Keychain items.
+ public static func migrateLocalKeysToICloud() {
+ for provider in LLMProvider.presets {
+ guard let local = readKey(providerId: provider.id, synchronizable: false), !local.isEmpty else {
+ continue
+ }
+ try? writeKey(local, providerId: provider.id, synchronizable: true)
+ try? deleteKey(providerId: provider.id, synchronizable: false)
+ }
+ }
+
// MARK: - Onboarding completion (reboot-durable flag)
- // App Group UserDefaults can transiently read empty right after a device
- // reboot (data protection / `cfprefsd` not warmed), which made the app
- // falsely re-show onboarding. This Keychain marker uses the same
- // `AfterFirstUnlockThisDeviceOnly` class — reliably readable once the app
- // can run, device-local, never synced — so it stays a trustworthy fallback
- // that survives the App Group read race.
private static let onboardingService = "com.osgkeyboard.onboarding"
private static let onboardingAccount = "hasCompletedOnboarding"
- /// Durable "user finished onboarding" marker. `false` when unset or unreadable.
public static func hasCompletedOnboarding() -> Bool {
- let query: [String: Any] = [
+ var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: onboardingService,
kSecAttrAccount as String: onboardingAccount,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
+ #if os(macOS)
+ query[kSecUseDataProtectionKeychain as String] = true
+ #endif
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
@@ -216,20 +226,20 @@ public enum Keychain: @unchecked Sendable {
return completed
}
- /// Mirror the onboarding-completed flag. Best-effort and idempotent — a
- /// no-op when the stored value already matches, so it can be called from
- /// frequently-saved config paths without Keychain churn.
public static func setOnboardingCompleted(_ completed: Bool) {
guard hasCompletedOnboarding() != completed else {
OSGLog.config.info("[onboarding] Keychain write skipped (already \(completed, privacy: .public))")
return
}
- let baseQuery: [String: Any] = [
+ var baseQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: onboardingService,
kSecAttrAccount as String: onboardingAccount,
]
+ #if os(macOS)
+ baseQuery[kSecUseDataProtectionKeychain as String] = true
+ #endif
guard completed else {
let delStatus = SecItemDelete(baseQuery as CFDictionary)
diff --git a/OSGKeyboardShared/Services/PersonalDictionaryCloudSync/PersonalDictionaryCloudSync.swift b/OSGKeyboardShared/Services/PersonalDictionaryCloudSync/PersonalDictionaryCloudSync.swift
index b1aaeff..bf47a6f 100644
--- a/OSGKeyboardShared/Services/PersonalDictionaryCloudSync/PersonalDictionaryCloudSync.swift
+++ b/OSGKeyboardShared/Services/PersonalDictionaryCloudSync/PersonalDictionaryCloudSync.swift
@@ -24,7 +24,8 @@ public enum PersonalDictionaryCloudSyncError: Error, Equatable, Sendable {
public final class PersonalDictionaryCloudSync {
public static let shared = PersonalDictionaryCloudSync()
- public static let kvsKey = "personalDictionary.v1"
+ public static let kvsKey = PersonalDictionary.kvsKeyV2
+ public static let legacyKVSKey = PersonalDictionary.legacyKVSKey
/// Stay below the ~1 MB per-key KVS limit.
public static let maxPayloadBytes = 900_000
@@ -124,8 +125,11 @@ public final class PersonalDictionaryCloudSync {
}
public func loadRemote() -> PersonalDictionary? {
- guard let data = kvs.data(forKey: Self.kvsKey) else { return nil }
- return try? decode(data)
+ 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)
}
// MARK: - Encoding
diff --git a/OSGKeyboardShared/Services/PolishingService.swift b/OSGKeyboardShared/Services/PolishingService.swift
index 9eb000c..94c764f 100644
--- a/OSGKeyboardShared/Services/PolishingService.swift
+++ b/OSGKeyboardShared/Services/PolishingService.swift
@@ -48,7 +48,7 @@ public actor PolishingService {
case translate(targetLocaleId: String)
}
- private let store: AppGroupStore
+ private let store: any ConfigurationStore
private let timeout: TimeInterval
/// Optional injected client (mostly for testing). When nil we build
/// one from `store.makeClient()` per call.
@@ -60,7 +60,7 @@ public actor PolishingService {
/// own slack on top of the length-scaled budget in `polishRemote`, so
/// no `+1` is baked in here.
public init(
- store: AppGroupStore = AppGroupStore(),
+ store: any ConfigurationStore = AppGroupStore(),
client: LLMClient? = nil,
timeout: TimeInterval? = nil
) {
@@ -346,7 +346,7 @@ public actor PolishingService {
}
internal static func resolvedProviderId(
- store: AppGroupStore,
+ store: any ConfigurationStore,
providerIdOverride: String?
) -> String {
if let providerIdOverride {
@@ -360,7 +360,7 @@ public actor PolishingService {
}
internal static func resolveLLMEndpoint(
- store: AppGroupStore,
+ store: any ConfigurationStore,
preset: LLMProvider,
providerIdOverride: String?
) -> (baseURL: String, model: String) {
diff --git a/OSGKeyboardShared/Services/SpeechHistoryStorage.swift b/OSGKeyboardShared/Services/SpeechHistoryStorage.swift
new file mode 100644
index 0000000..6753148
--- /dev/null
+++ b/OSGKeyboardShared/Services/SpeechHistoryStorage.swift
@@ -0,0 +1,79 @@
+// SpeechHistoryStorage.swift
+// OSGKeyboard · Shared
+//
+// Local persistence for the speech history payload (entries + tombstones).
+
+import Foundation
+
+public enum SpeechHistoryStorage {
+ public static let storageKey = SyncedSpeechHistory.kvsKey
+ /// Pre-unification iOS history in `UserDefaults.standard`.
+ public static let legacyIOSEntriesKey = "speechHistory.entries.v1"
+ /// Pre-unification macOS history in `UserDefaults.standard`.
+ public static let legacyMacHistoryKey = "mac.history"
+
+ public static func load(from defaults: UserDefaults) -> SyncedSpeechHistory {
+ if let data = defaults.data(forKey: storageKey),
+ let history = try? JSONDecoder().decode(SyncedSpeechHistory.self, from: data) {
+ return history
+ }
+ return migrateLegacyIfNeeded(into: defaults)
+ }
+
+ public static func save(_ history: SyncedSpeechHistory, to defaults: UserDefaults) {
+ guard let data = try? JSONEncoder().encode(history) else { return }
+ defaults.set(data, forKey: storageKey)
+ }
+
+ /// Import older per-platform keys once, then persist the unified payload.
+ public static func migrateLegacyIfNeeded(into defaults: UserDefaults) -> SyncedSpeechHistory {
+ var entries: [SpeechHistoryEntry] = []
+
+ if let data = defaults.data(forKey: legacyIOSEntriesKey),
+ let legacy = try? JSONDecoder().decode([LegacyIOSHistoryEntry].self, from: data) {
+ entries.append(contentsOf: legacy.map {
+ SpeechHistoryEntry(
+ id: $0.id,
+ text: $0.text,
+ createdAt: $0.createdAt,
+ engineMode: $0.engineMode
+ )
+ })
+ defaults.removeObject(forKey: legacyIOSEntriesKey)
+ }
+
+ if let data = defaults.data(forKey: legacyMacHistoryKey),
+ let legacy = try? JSONDecoder().decode([LegacyMacHistoryRecord].self, from: data) {
+ entries.append(contentsOf: legacy.map {
+ SpeechHistoryEntry(id: $0.id, text: $0.text, createdAt: $0.date, engineMode: nil)
+ })
+ defaults.removeObject(forKey: legacyMacHistoryKey)
+ }
+
+ guard !entries.isEmpty else { return .empty }
+
+ var history = SyncedSpeechHistory(updatedAt: Date(), entries: [])
+ for entry in entries {
+ history.entries.append(entry)
+ }
+ history.entries.sort { $0.createdAt > $1.createdAt }
+ history.trimEntries()
+ save(history, to: defaults)
+ return history
+ }
+}
+
+// MARK: - Legacy decoding
+
+private struct LegacyIOSHistoryEntry: Codable {
+ let id: UUID
+ let text: String
+ let createdAt: Date
+ let engineMode: String
+}
+
+private struct LegacyMacHistoryRecord: Codable {
+ let id: UUID
+ let text: String
+ let date: Date
+}
diff --git a/OSGKeyboardShared/Services/SpeechHistoryStore.swift b/OSGKeyboardShared/Services/SpeechHistoryStore.swift
new file mode 100644
index 0000000..3bd4742
--- /dev/null
+++ b/OSGKeyboardShared/Services/SpeechHistoryStore.swift
@@ -0,0 +1,95 @@
+// SpeechHistoryStore.swift
+// OSGKeyboard · Shared
+//
+// Observable store for voice transcription history. Mirrored through
+// iCloud KVS when settings sync is enabled.
+
+import Combine
+import Foundation
+
+@MainActor
+public final class SpeechHistoryStore: ObservableObject {
+ public static let shared = SpeechHistoryStore()
+
+ @Published public private(set) var entries: [SpeechHistoryEntry] = []
+
+ public let defaults: UserDefaults
+ private var payload: SyncedSpeechHistory = .empty
+
+ public init(defaults: UserDefaults = .standard) {
+ self.defaults = defaults
+ reloadFromDisk()
+ NotificationCenter.default.addObserver(
+ forName: .speechHistoryDidSyncFromCloud,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ Task { @MainActor in
+ self?.reloadFromDisk()
+ }
+ }
+ }
+
+ public func append(text: String, engineMode: String? = nil) {
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return }
+
+ let entry = SpeechHistoryEntry(text: trimmed, engineMode: engineMode)
+ payload.entries.insert(entry, at: 0)
+ payload.trimEntries()
+ payload.updatedAt = Date()
+ applyPayload(postCloudPush: true)
+ }
+
+ public func delete(id: UUID) {
+ guard payload.entries.contains(where: { $0.id == id }) else { return }
+ payload.deletedEntryIDs[id] = Date()
+ payload.entries.removeAll { $0.id == id }
+ payload.updatedAt = Date()
+ payload.pruneTombstonesIfNeeded()
+ applyPayload(postCloudPush: true)
+ }
+
+ public func clearAll() {
+ payload.recordClearAll()
+ payload.updatedAt = Date()
+ payload.pruneTombstonesIfNeeded()
+ applyPayload(postCloudPush: true)
+ }
+
+ public func snapshot() -> SyncedSpeechHistory {
+ payload
+ }
+
+ public func apply(_ history: SyncedSpeechHistory) {
+ payload = history
+ entries = history.entries.sorted { $0.createdAt > $1.createdAt }
+ }
+
+ public func reloadFromDisk() {
+ payload = SpeechHistoryStorage.load(from: defaults)
+ entries = payload.entries.sorted { $0.createdAt > $1.createdAt }
+ }
+
+ /// Entries grouped by calendar day (newest day first).
+ public 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 applyPayload(postCloudPush: Bool) {
+ entries = payload.entries.sorted { $0.createdAt > $1.createdAt }
+ SpeechHistoryStorage.save(payload, to: defaults)
+ guard postCloudPush else { return }
+ Task {
+ try? await SpeechHistoryCloudSync.shared.pushLocalIfEnabled()
+ }
+ }
+}
diff --git a/OSGKeyboardShared/Services/UsageStatisticsStore.swift b/OSGKeyboardShared/Services/UsageStatisticsStore.swift
new file mode 100644
index 0000000..75ed0af
--- /dev/null
+++ b/OSGKeyboardShared/Services/UsageStatisticsStore.swift
@@ -0,0 +1,125 @@
+// UsageStatisticsStore.swift
+// OSGKeyboard · Shared
+//
+// Observable store for cumulative usage metrics. Updated after each
+// successful dictation on iOS Flow and macOS menu-bar capture.
+
+import Combine
+import Foundation
+
+@MainActor
+public final class UsageStatisticsStore: ObservableObject {
+ public static let shared = UsageStatisticsStore()
+
+ @Published public private(set) var dictationDurationSeconds: TimeInterval = 0
+ @Published public private(set) var dictationCharacterCount: Int = 0
+ @Published public private(set) var translationCharacterCount: Int = 0
+
+ public let defaults: UserDefaults
+
+ /// Marks the one-time purge of statistics corrupted by the pre-fix
+ /// double-counting bug (see `purgeCorruptedStatsIfNeeded`).
+ private static let dirtyResetFlagKey = "usageStatistics.dirtyReset.v1"
+
+ public init(defaults: UserDefaults? = nil) {
+ self.defaults = defaults ?? AppGroupStore().defaults
+ purgeCorruptedStatsIfNeeded()
+ reloadFromDisk()
+ NotificationCenter.default.addObserver(
+ forName: .usageStatisticsDidSyncFromCloud,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ Task { @MainActor in
+ self?.reloadFromDisk()
+ }
+ }
+ }
+
+ public 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)
+
+ // Increment ONLY this device's own slice. The displayed totals are the
+ // cross-device *sum* (see `reloadFromDisk`), so incrementing in-memory
+ // display state and writing it back as this device's slice would fold
+ // every other device's total into this one and double-count on the
+ // next reload — the bug that inflated one slice to ~8× the real usage.
+ let deviceID = SyncDeviceID.current(defaults: defaults)
+ var slice = SyncedUsageStatisticsStorage.currentDeviceSlice(from: defaults, deviceID: deviceID)
+ if wasTranslation {
+ slice.translationCharacterCount += count
+ } else {
+ slice.dictationCharacterCount += count
+ }
+ slice.dictationDurationSeconds += max(0, duration)
+ slice.updatedAt = Date()
+ SyncedUsageStatisticsStorage.upsertCurrentDeviceSlice(slice, defaults: defaults, deviceID: deviceID)
+
+ reloadFromDisk()
+
+ Task {
+ try? await UsageStatisticsCloudSync.shared.pushLocalIfEnabled()
+ }
+ }
+
+ /// Refreshes the published totals from disk. Display-only: it reads the
+ /// aggregated cross-device sum and NEVER writes it back (writing would
+ /// corrupt the per-device slices — see `recordUtterance`).
+ public func reloadFromDisk() {
+ let aggregated = SyncedUsageStatisticsStorage.load(from: defaults).aggregated
+ dictationDurationSeconds = aggregated.dictationDurationSeconds
+ dictationCharacterCount = aggregated.dictationCharacterCount
+ translationCharacterCount = aggregated.translationCharacterCount
+ }
+
+ /// One-time cleanup: the pre-fix code overwrote a device slice with the
+ /// cross-device *sum*, so every reload/record re-added the other devices'
+ /// totals and one slice ballooned to ~8× the true usage. We can't recover
+ /// the true per-device split from corrupted data, so wipe local + remote
+ /// once and let the corrected per-device accounting re-accumulate cleanly.
+ private func purgeCorruptedStatsIfNeeded() {
+ guard !defaults.bool(forKey: Self.dirtyResetFlagKey) else { return }
+ defaults.set(true, forKey: Self.dirtyResetFlagKey)
+
+ defaults.removeObject(forKey: SyncedUsageStatisticsStorage.storageKey)
+ defaults.removeObject(forKey: UsageStatisticsStorage.storageKey)
+ defaults.removeObject(forKey: UsageStatisticsStorage.legacyMacTotalWordsKey)
+
+ UsageStatisticsCloudSync.shared.purgeRemote()
+ }
+
+ public static func characterCount(for text: String) -> Int {
+ text.trimmingCharacters(in: .whitespacesAndNewlines).count
+ }
+
+ // MARK: - Formatting
+
+ public 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"
+ }
+
+ public 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)"
+ }
+}
diff --git a/OSGKeyboardShared/en.lproj/Shared.strings b/OSGKeyboardShared/en.lproj/Shared.strings
index 4cdeddd..02b30f9 100644
--- a/OSGKeyboardShared/en.lproj/Shared.strings
+++ b/OSGKeyboardShared/en.lproj/Shared.strings
@@ -99,3 +99,105 @@
"keyboard.translation.offMenu" = "Don't translate";
"keyboard.translation.a11y" = "Translation";
"keyboard.translation.a11yHint" = "Toggle translation or change the target language.";
+
+/* macOS app */
+"mac.section.dashboard" = "Dashboard";
+"mac.section.history" = "History";
+"mac.section.dictionary" = "Personal Dictionary";
+"mac.section.settings" = "Settings";
+"mac.brand.subtitle" = "AI DICTATION";
+"mac.devices" = "Devices";
+"mac.status.ready" = "Ready to dictate…";
+"mac.status.listening" = "Listening…";
+"mac.status.transcribing" = "Transcribing…";
+"mac.status.copied" = "Copied to clipboard";
+"mac.status.pasted" = "Inserted into front app";
+"mac.status.copiedAndPasted" = "Copied and inserted";
+"mac.stat.dictationTime" = "Dictation Time";
+"mac.stat.words" = "Dictation Chars";
+"mac.stat.translation" = "Translation Chars";
+"mac.stat.dictionary" = "Dictionary";
+"mac.stat.cumulativeDuration" = "Total time";
+"mac.stat.transcribed" = "Transcribed";
+"mac.stat.cumulativeTranslation" = "Translated";
+"mac.stat.customTerms" = "Custom terms";
+"mac.status.chipReady" = "Ready";
+"mac.status.chipProcessing" = "Processing";
+"mac.record.start" = "Record";
+"mac.record.stop" = "Stop";
+"mac.record.pressStop" = "Press Stop";
+"mac.copy" = "Copy";
+"mac.openWindow" = "Open Window";
+"mac.quit" = "Quit";
+"mac.mode.cloud" = "Cloud Mode";
+"mac.mode.local" = "Local Mode";
+"mac.connected" = "Connected";
+"mac.offline" = "Offline";
+"mac.history.recent" = "Recent";
+"mac.history.empty" = "No voice transcripts yet.";
+"mac.history.select" = "Select a dictation";
+"mac.history.clearTitle" = "Clear all history?";
+"mac.history.clearMessage" = "This cannot be undone.";
+"mac.history.clearConfirm" = "Clear all";
+"mac.dict.health" = "Vocabulary Health";
+"mac.dict.healthDesc" = "Custom terms that bias recognition and are never rewritten.";
+"mac.dict.search" = "Search words";
+"mac.dict.empty" = "No words yet";
+"mac.dict.emptyBody" = "Add words on your iPhone or iPad to improve recognition accuracy. They sync here via iCloud.";
+"mac.dict.noMatch" = "No matches";
+"mac.cancel" = "Cancel";
+"mac.delete" = "Delete";
+"mac.dict.deleteTitle" = "Delete this word?";
+"mac.dict.deleteMessage" = "This cannot be undone.";
+"mac.hint.holdOption" = "Hold Option to dictate";
+"mac.settings.cloudProvider" = "CLOUD PROVIDER";
+"mac.settings.service" = "Service";
+"mac.settings.apiKey" = "API Key";
+"mac.settings.model" = "Model";
+"mac.settings.recognition" = "RECOGNITION METHOD";
+"mac.settings.cloudEngine" = "Cloud Engine & AI Refinement";
+"mac.settings.cloudEngineDesc" = "Premium transcription via your provider's API, plus AI grammar and style polishing.";
+"mac.settings.localEngine" = "Local Recognition (Qwen3-ASR)";
+"mac.settings.localEngineDesc" = "On-device ASR with Qwen3-ASR 1.7B (MLX). High privacy, zero latency.";
+"mac.settings.localSpeechFallback" = "Local Recognition (Apple Speech)";
+"mac.settings.localSpeechFallbackDesc" = "On-device Apple Speech when the Qwen3 model is not installed.";
+"mac.settings.about" = "About";
+"mac.settings.general" = "General";
+"mac.settings.input" = "Input & Shortcuts";
+"mac.settings.recognitionLanguage" = "Recognition Language";
+"mac.settings.interfaceLanguage" = "Interface Language";
+"mac.settings.autoPaste" = "Auto-paste after dictation";
+"mac.settings.autoPasteDesc" = "Simulate ⌘V in the front app after transcription (requires Accessibility).";
+"mac.settings.hotkey" = "Global shortcut";
+"mac.settings.hotkeyDesc" = "Hold Option (⌥) to dictate from any app.";
+"mac.settings.qwen3Model" = "Qwen3 model folder";
+"mac.settings.qwen3ModelDesc" = "Folder with config.json, model.safetensors, vocab.json, and merges.txt.";
+"mac.settings.qwen3Browse" = "Choose folder…";
+"mac.settings.qwen3Missing" = "Qwen3 model not found — using Apple Speech for now.";
+"mac.settings.accessibility" = "Accessibility";
+"mac.settings.accessibilityDesc" = "Required for global shortcut and auto-paste.";
+"mac.settings.openAccessibility" = "Open System Settings";
+"mac.settings.appearance" = "Appearance";
+"mac.settings.appearanceDesc" = "Match the system, or force a light or dark look.";
+"mac.appearance.system" = "System";
+"mac.appearance.light" = "Light";
+"mac.appearance.dark" = "Dark";
+"mac.error.noAudio" = "No audio captured";
+"mac.error.noCloudASR" = "Selected provider has no cloud ASR";
+"mac.error.emptyTranscript" = "No speech recognized";
+"mac.error.qwen3ModelMissing" = "Qwen3-ASR model not installed";
+"mac.error.qwen3LoadFailed" = "Failed to load Qwen3 model: %@";
+"mac.error.qwen3InferenceFailed" = "Qwen3 transcription failed: %@";
+"mac.error.accessibilityRequired" = "Enable Accessibility for OSGKeyboard in System Settings";
+"mac.foregroundApp" = "Front app: %@";
+"mac.sync.settingsTitle" = "iCloud Sync";
+"mac.sync.settingsSubtitle" = "Sync settings, usage stats, speech history, and API keys across your devices via iCloud.";
+"mac.sync.syncNow" = "Sync Now";
+"mac.sync.dictTitle" = "Personal dictionary iCloud sync";
+"mac.sync.dictSubtitle" = "Keep your dictionary in sync across all devices.";
+"mac.sync.error.generic" = "Could not sync with iCloud. Try again later.";
+"mac.sync.error.dictTooLarge" = "Dictionary is too large to sync via iCloud.";
+
+"settings.appLanguage.auto" = "Auto";
+"settings.appLanguage.english" = "English";
+"settings.appLanguage.chinese" = "Chinese";
diff --git a/OSGKeyboardShared/zh-Hans.lproj/Shared.strings b/OSGKeyboardShared/zh-Hans.lproj/Shared.strings
index 32b9cf3..0aa9f21 100644
--- a/OSGKeyboardShared/zh-Hans.lproj/Shared.strings
+++ b/OSGKeyboardShared/zh-Hans.lproj/Shared.strings
@@ -99,3 +99,105 @@
"keyboard.translation.offMenu" = "不翻译";
"keyboard.translation.a11y" = "翻译";
"keyboard.translation.a11yHint" = "切换翻译或更改目标语言。";
+
+/* macOS 应用 */
+"mac.section.dashboard" = "仪表盘";
+"mac.section.history" = "历史";
+"mac.section.dictionary" = "个性词库";
+"mac.section.settings" = "设置";
+"mac.brand.subtitle" = "AI 听写";
+"mac.devices" = "设备";
+"mac.status.ready" = "准备听写…";
+"mac.status.listening" = "录音中…";
+"mac.status.transcribing" = "识别中…";
+"mac.status.copied" = "已复制到剪贴板";
+"mac.status.pasted" = "已插入前台应用";
+"mac.status.copiedAndPasted" = "已复制并插入";
+"mac.stat.dictationTime" = "听写时长";
+"mac.stat.words" = "听写字数";
+"mac.stat.translation" = "翻译字数";
+"mac.stat.dictionary" = "词库";
+"mac.stat.cumulativeDuration" = "累计时长";
+"mac.stat.transcribed" = "累计转写";
+"mac.stat.cumulativeTranslation" = "累计翻译";
+"mac.stat.customTerms" = "自定义词条";
+"mac.status.chipReady" = "就绪";
+"mac.status.chipProcessing" = "处理中";
+"mac.record.start" = "开始录音";
+"mac.record.stop" = "停止";
+"mac.record.pressStop" = "点击停止";
+"mac.copy" = "复制";
+"mac.openWindow" = "打开主窗口";
+"mac.quit" = "退出";
+"mac.mode.cloud" = "云端模式";
+"mac.mode.local" = "本地模式";
+"mac.connected" = "已连接";
+"mac.offline" = "离线";
+"mac.history.recent" = "最近";
+"mac.history.empty" = "还没有语音识别记录。";
+"mac.history.select" = "选择一条记录";
+"mac.history.clearTitle" = "清空全部历史?";
+"mac.history.clearMessage" = "此操作无法撤销。";
+"mac.history.clearConfirm" = "全部清空";
+"mac.dict.health" = "词库健康度";
+"mac.dict.healthDesc" = "影响识别偏置且润色时不会被改写的自定义词条。";
+"mac.dict.search" = "搜索词条";
+"mac.dict.empty" = "还没有词条";
+"mac.dict.emptyBody" = "在 iPhone 或 iPad 上添加词条以提升识别准确性,它们会通过 iCloud 同步到这里。";
+"mac.dict.noMatch" = "无匹配结果";
+"mac.cancel" = "取消";
+"mac.delete" = "删除";
+"mac.dict.deleteTitle" = "删除该词条?";
+"mac.dict.deleteMessage" = "此操作无法撤销。";
+"mac.hint.holdOption" = "长按 Option 开始听写";
+"mac.settings.cloudProvider" = "云端服务商";
+"mac.settings.service" = "服务商";
+"mac.settings.apiKey" = "API 密钥";
+"mac.settings.model" = "模型";
+"mac.settings.recognition" = "识别方式";
+"mac.settings.cloudEngine" = "云端引擎与 AI 润色";
+"mac.settings.cloudEngineDesc" = "通过服务商 API 进行高质量转写,并自动润色语法与风格。";
+"mac.settings.localEngine" = "本地识别(Qwen3-ASR)";
+"mac.settings.localEngineDesc" = "使用 Qwen3-ASR 1.7B(MLX)本地转写,高隐私、低延迟。";
+"mac.settings.localSpeechFallback" = "本地识别(Apple Speech)";
+"mac.settings.localSpeechFallbackDesc" = "未安装 Qwen3 模型时,使用 Apple 本地语音识别。";
+"mac.settings.about" = "关于";
+"mac.settings.general" = "通用";
+"mac.settings.input" = "输入与快捷键";
+"mac.settings.recognitionLanguage" = "识别语言";
+"mac.settings.interfaceLanguage" = "界面语言";
+"mac.settings.autoPaste" = "听写后自动粘贴";
+"mac.settings.autoPasteDesc" = "转写完成后向前台应用模拟 ⌘V(需辅助功能权限)。";
+"mac.settings.hotkey" = "全局快捷键";
+"mac.settings.hotkeyDesc" = "按住 Option (⌥) 键即可从任意应用开始听写。";
+"mac.settings.qwen3Model" = "Qwen3 模型目录";
+"mac.settings.qwen3ModelDesc" = "需包含 config.json、model.safetensors、vocab.json 与 merges.txt。";
+"mac.settings.qwen3Browse" = "选择文件夹…";
+"mac.settings.qwen3Missing" = "未找到 Qwen3 模型,暂时使用 Apple Speech。";
+"mac.settings.accessibility" = "辅助功能";
+"mac.settings.accessibilityDesc" = "全局快捷键与自动粘贴需要此权限。";
+"mac.settings.openAccessibility" = "打开系统设置";
+"mac.settings.appearance" = "外观";
+"mac.settings.appearanceDesc" = "跟随系统,或强制使用浅色 / 深色。";
+"mac.appearance.system" = "跟随系统";
+"mac.appearance.light" = "浅色";
+"mac.appearance.dark" = "深色";
+"mac.error.noAudio" = "没有捕获到音频";
+"mac.error.noCloudASR" = "当前服务商不支持云端语音识别";
+"mac.error.emptyTranscript" = "没有识别到语音";
+"mac.error.qwen3ModelMissing" = "未安装 Qwen3-ASR 模型";
+"mac.error.qwen3LoadFailed" = "Qwen3 模型加载失败:%@";
+"mac.error.qwen3InferenceFailed" = "Qwen3 转写失败:%@";
+"mac.error.accessibilityRequired" = "请在系统设置中为 OSGKeyboard 启用辅助功能";
+"mac.foregroundApp" = "前台应用:%@";
+"mac.sync.settingsTitle" = "iCloud 同步";
+"mac.sync.settingsSubtitle" = "通过 iCloud 在多台设备间同步设置、使用统计、语音历史与 API 密钥。";
+"mac.sync.syncNow" = "立即同步";
+"mac.sync.dictTitle" = "个人词库 iCloud 同步";
+"mac.sync.dictSubtitle" = "在所有设备间同步个人词库。";
+"mac.sync.error.generic" = "无法与 iCloud 同步,请稍后重试。";
+"mac.sync.error.dictTooLarge" = "词库过大,无法通过 iCloud 同步。";
+
+"settings.appLanguage.auto" = "自动";
+"settings.appLanguage.english" = "英文";
+"settings.appLanguage.chinese" = "中文";
diff --git a/OSGKeyboardTests/ConfigurationStoreTests.swift b/OSGKeyboardTests/ConfigurationStoreTests.swift
new file mode 100644
index 0000000..b66f0ca
--- /dev/null
+++ b/OSGKeyboardTests/ConfigurationStoreTests.swift
@@ -0,0 +1,44 @@
+// ConfigurationStoreTests.swift
+// OSGKeyboardTests
+//
+// Locks `AppGroupStore` conformance to `ConfigurationStore` and ensures
+// pipeline helpers accept the protocol without changing iOS behavior.
+
+import XCTest
+@testable import OSGKeyboardShared
+
+final class ConfigurationStoreTests: XCTestCase {
+ private var suiteName: String!
+ private var defaults: UserDefaults!
+ private var store: AppGroupStore!
+
+ override func setUp() {
+ super.setUp()
+ suiteName = "group.com.osgkeyboard.shared.tests.configuration.\(UUID().uuidString)"
+ defaults = UserDefaults(suiteName: suiteName)!
+ defaults.removePersistentDomain(forName: suiteName)
+ store = AppGroupStore(defaults: defaults)
+ }
+
+ override func tearDown() {
+ defaults.removePersistentDomain(forName: suiteName)
+ defaults = nil
+ store = nil
+ super.tearDown()
+ }
+
+ func testAppGroupStoreConformsToConfigurationStore() {
+ let configuration: any ConfigurationStore = store
+ XCTAssertEqual(configuration.cloudASRPersistence, defaults)
+ XCTAssertEqual(
+ PolishingService.resolvedProviderId(store: configuration, providerIdOverride: nil),
+ PolishingService.resolvedProviderId(store: store, providerIdOverride: nil)
+ )
+ }
+
+ func testASRFactoryAcceptsConfigurationStore() {
+ store.setEngineMode("local")
+ let service = ASRServiceFactory.make(store: store as any ConfigurationStore)
+ XCTAssertTrue(service is SpeechAnalyzerASR)
+ }
+}
diff --git a/OSGKeyboardTests/FlowSessionBridgeTests.swift b/OSGKeyboardTests/FlowSessionBridgeTests.swift
index eae94bd..272cf61 100644
--- a/OSGKeyboardTests/FlowSessionBridgeTests.swift
+++ b/OSGKeyboardTests/FlowSessionBridgeTests.swift
@@ -141,5 +141,46 @@ final class FlowSessionBridgeTests: XCTestCase {
func testDarwinNotificationPostsWithoutCrashing() {
FlowSessionDarwin.postSessionChanged()
+ FlowSessionDarwin.postHostReadyChanged()
+ }
+
+ func testHostReadyRequiresExplicitContract() {
+ let defaults = makeDefaults()
+ FlowSessionBridge.markSessionActive(duration: 60, defaults: defaults)
+ XCTAssertTrue(FlowSessionBridge.isHostReachable(defaults: defaults))
+ XCTAssertFalse(FlowSessionBridge.isHostReady(defaults: defaults))
+
+ FlowSessionBridge.setHostReady(true, defaults: defaults)
+ XCTAssertTrue(FlowSessionBridge.isHostReady(defaults: defaults))
+ }
+
+ func testHostReadyFalseWhenHeartbeatStale() {
+ let defaults = makeDefaults()
+ FlowSessionBridge.markSessionActive(duration: 3_600, defaults: defaults)
+ FlowSessionBridge.setHostReady(true, defaults: defaults)
+ XCTAssertTrue(FlowSessionBridge.isHostReady(defaults: defaults))
+
+ let staleHeartbeat = Date().timeIntervalSince1970 - 10
+ defaults.set(staleHeartbeat, forKey: FlowSessionKeys.flowHeartbeat)
+ XCTAssertFalse(FlowSessionBridge.isHostReady(defaults: defaults))
+ }
+
+ func testHeartbeatRefreshKeepsHostReadyPublished() {
+ let defaults = makeDefaults()
+ FlowSessionBridge.markSessionActive(duration: 3_600, defaults: defaults)
+ FlowSessionBridge.setHostReady(true, defaults: defaults)
+
+ FlowSessionBridge.writeHeartbeat(defaults: defaults)
+
+ XCTAssertTrue(FlowSessionBridge.isHostReady(defaults: defaults))
+ }
+
+ func testClearFlowStateClearsHostReady() {
+ let defaults = makeDefaults()
+ FlowSessionBridge.markSessionActive(defaults: defaults)
+ FlowSessionBridge.setHostReady(true, defaults: defaults)
+ FlowSessionBridge.clearFlowState(defaults: defaults)
+ XCTAssertFalse(defaults.bool(forKey: FlowSessionKeys.flowHostReady))
+ XCTAssertFalse(FlowSessionBridge.isHostReady(defaults: defaults))
}
}
diff --git a/OSGKeyboardTests/MicVoiceAvailabilityTests.swift b/OSGKeyboardTests/MicVoiceAvailabilityTests.swift
new file mode 100644
index 0000000..1c57e28
--- /dev/null
+++ b/OSGKeyboardTests/MicVoiceAvailabilityTests.swift
@@ -0,0 +1,80 @@
+// MicVoiceAvailabilityTests.swift
+// OSGKeyboardTests
+
+import XCTest
+@testable import OSGKeyboardShared
+
+final class MicVoiceAvailabilityTests: XCTestCase {
+
+ func testReadyWhenHostReadyAndIdle() {
+ let availability = MicVoiceAvailabilityResolver.resolve(
+ phase: .idle,
+ micDisabled: false,
+ hasFullAccess: true,
+ appGroupAvailable: true,
+ hostReady: true,
+ isPreparingSession: false
+ )
+ XCTAssertEqual(availability, .ready)
+ }
+
+ func testUnavailableWhenMissingAPIKey() {
+ let availability = MicVoiceAvailabilityResolver.resolve(
+ phase: .idle,
+ micDisabled: true,
+ hasFullAccess: true,
+ appGroupAvailable: true,
+ hostReady: true,
+ isPreparingSession: false
+ )
+ XCTAssertEqual(availability, .unavailable(.missingAPIKey))
+ }
+
+ func testUnavailableWhenHostNotReady() {
+ let availability = MicVoiceAvailabilityResolver.resolve(
+ phase: .idle,
+ micDisabled: false,
+ hasFullAccess: true,
+ appGroupAvailable: true,
+ hostReady: false,
+ isPreparingSession: false
+ )
+ XCTAssertEqual(availability, .unavailable(.hostNotReady))
+ }
+
+ func testUnavailableWhenPreparingSession() {
+ let availability = MicVoiceAvailabilityResolver.resolve(
+ phase: .idle,
+ micDisabled: false,
+ hasFullAccess: true,
+ appGroupAvailable: true,
+ hostReady: false,
+ isPreparingSession: true
+ )
+ XCTAssertEqual(availability, .unavailable(.preparingSession))
+ }
+
+ func testRecordingOverridesReady() {
+ let availability = MicVoiceAvailabilityResolver.resolve(
+ phase: .recording,
+ micDisabled: false,
+ hasFullAccess: true,
+ appGroupAvailable: true,
+ hostReady: true,
+ isPreparingSession: false
+ )
+ XCTAssertEqual(availability, .recording)
+ }
+
+ func testProcessingOverridesUnavailable() {
+ let availability = MicVoiceAvailabilityResolver.resolve(
+ phase: .processing,
+ micDisabled: false,
+ hasFullAccess: true,
+ appGroupAvailable: true,
+ hostReady: false,
+ isPreparingSession: false
+ )
+ XCTAssertEqual(availability, .processing)
+ }
+}
diff --git a/OSGKeyboardTests/PersonalDictionaryMergeTests.swift b/OSGKeyboardTests/PersonalDictionaryMergeTests.swift
new file mode 100644
index 0000000..5c74b36
--- /dev/null
+++ b/OSGKeyboardTests/PersonalDictionaryMergeTests.swift
@@ -0,0 +1,59 @@
+// PersonalDictionaryMergeTests.swift
+// OSGKeyboardTests
+//
+// Hermetic tests for dictionary tombstone merge semantics.
+
+import XCTest
+@testable import OSGKeyboardShared
+
+final class PersonalDictionaryMergeTests: XCTestCase {
+ func testDeletedEntryDoesNotResurrectFromRemote() {
+ let id = UUID()
+ let deletedAt = Date()
+ let local = PersonalDictionary(
+ entries: [],
+ deletedEntryIDs: [id: deletedAt]
+ )
+ let remote = PersonalDictionary(
+ entries: [
+ PersonalDictionary.Entry(
+ id: id,
+ term: "OSG",
+ category: .productName,
+ source: .manual
+ ),
+ ]
+ )
+
+ let merged = PersonalDictionary.merge(local: local, remote: remote)
+
+ XCTAssertTrue(merged.entries.isEmpty)
+ XCTAssertEqual(merged.deletedEntryIDs[id], deletedAt)
+ }
+
+ func testClearAllExcludesOlderRemoteEntries() {
+ let clearedAt = Date(timeIntervalSince1970: 500)
+ let local = PersonalDictionary(entries: [], clearedAt: clearedAt)
+ let remote = PersonalDictionary(
+ entries: [
+ PersonalDictionary.Entry(
+ term: "old",
+ category: .custom,
+ source: .manual,
+ createdAt: Date(timeIntervalSince1970: 100)
+ ),
+ PersonalDictionary.Entry(
+ term: "new",
+ category: .custom,
+ source: .manual,
+ createdAt: Date(timeIntervalSince1970: 600)
+ ),
+ ]
+ )
+
+ let merged = PersonalDictionary.merge(local: local, remote: remote)
+
+ XCTAssertEqual(merged.entries.count, 1)
+ XCTAssertEqual(merged.entries.first?.term, "new")
+ }
+}
diff --git a/OSGKeyboardTests/SettingsCloudSyncTests.swift b/OSGKeyboardTests/SettingsCloudSyncTests.swift
index 3b3c836..f67105b 100644
--- a/OSGKeyboardTests/SettingsCloudSyncTests.swift
+++ b/OSGKeyboardTests/SettingsCloudSyncTests.swift
@@ -14,12 +14,15 @@ final class SettingsCloudSyncTests: XCTestCase {
private var store: AppGroupStore!
private var kvs: FakeUbiquitousKeyValueStore!
private var settingsSync: SettingsCloudSync!
+ private let deviceA = "device-a"
+ private let deviceB = "device-b"
override func setUp() {
super.setUp()
suiteName = "group.com.osgkeyboard.shared.tests.settings.\(UUID().uuidString)"
defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
+ defaults.set(deviceA, forKey: "sync.deviceID.v1")
store = AppGroupStore(defaults: defaults)
kvs = FakeUbiquitousKeyValueStore()
settingsSync = SettingsCloudSync(kvs: kvs) { [unowned self] in store }
@@ -27,87 +30,66 @@ final class SettingsCloudSyncTests: XCTestCase {
override func tearDown() {
defaults.removePersistentDomain(forName: suiteName)
+ try? Keychain.deleteAPIKey(for: "openai", useICloudSync: false)
+ try? Keychain.deleteAPIKey(for: "openai", useICloudSync: true)
+ try? Keychain.deleteAPIKey(for: "qwen", useICloudSync: false)
+ try? Keychain.deleteAPIKey(for: "qwen", useICloudSync: true)
super.tearDown()
}
- func testMergePrefersNewerUpdatedAt() {
- let older = SyncedAppSettings(
- updatedAt: Date(timeIntervalSince1970: 100),
- providerId: "openai",
- baseURL: "https://old.example",
- model: "gpt-old",
- modeId: "polish",
- localeId: "auto",
- engineMode: "cloud",
- hasAcknowledgedCloudSharing: false,
- uiLanguage: .english,
- translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
- handednessPreference: .left,
- cursorDragNavigationEnabled: true,
- polishIntensity: .medium,
- flowSkipAppSwitch: true,
- flowInactivityDuration: .twelveHours
+ func testPerFieldMergeKeepsIndependentChanges() {
+ let stampA = Date(timeIntervalSince1970: 100)
+ let stampB = Date(timeIntervalSince1970: 200)
+ let local = SyncedAppSettingsV2(
+ providerId: SyncedField(value: "openai", updatedAt: stampA, deviceID: deviceA),
+ baseURL: SyncedField(value: "https://local.example", updatedAt: stampA, deviceID: deviceA),
+ model: SyncedField(value: "gpt-local", updatedAt: stampA, deviceID: deviceA),
+ modeId: SyncedField(value: "polish", updatedAt: stampA, deviceID: deviceA),
+ localeId: SyncedField(value: "auto", updatedAt: stampA, deviceID: deviceA),
+ engineMode: SyncedField(value: "cloud", updatedAt: stampA, deviceID: deviceA),
+ hasAcknowledgedCloudSharing: SyncedField(value: false, updatedAt: stampA, deviceID: deviceA),
+ uiLanguage: SyncedField(value: .english, updatedAt: stampA, deviceID: deviceA),
+ translationTargetLocaleId: SyncedField(
+ value: TranslationLanguageCatalog.offLocaleId,
+ updatedAt: stampA,
+ deviceID: deviceA
+ ),
+ handednessPreference: SyncedField(value: .left, updatedAt: stampA, deviceID: deviceA),
+ cursorDragNavigationEnabled: SyncedField(value: true, updatedAt: stampA, deviceID: deviceA),
+ polishIntensity: SyncedField(value: .medium, updatedAt: stampA, deviceID: deviceA),
+ flowSkipAppSwitch: SyncedField(value: true, updatedAt: stampA, deviceID: deviceA),
+ flowInactivityDuration: SyncedField(value: .twelveHours, updatedAt: stampA, deviceID: deviceA)
)
- let newer = SyncedAppSettings(
- updatedAt: Date(timeIntervalSince1970: 200),
- providerId: "openai",
- baseURL: "https://new.example",
- model: "gpt-new",
- modeId: "polish",
- localeId: "zh-Hans",
- engineMode: "local",
- hasAcknowledgedCloudSharing: true,
- uiLanguage: .chinese,
- translationTargetLocaleId: "en",
- handednessPreference: .right,
- cursorDragNavigationEnabled: false,
- polishIntensity: .light,
- flowSkipAppSwitch: false,
- flowInactivityDuration: .threeHours
+ let remote = SyncedAppSettingsV2(
+ providerId: SyncedField(value: "openai", updatedAt: stampA, deviceID: deviceB),
+ baseURL: SyncedField(value: "https://remote.example", updatedAt: stampB, deviceID: deviceB),
+ model: SyncedField(value: "gpt-remote", updatedAt: stampB, deviceID: deviceB),
+ modeId: SyncedField(value: "polish", updatedAt: stampA, deviceID: deviceB),
+ localeId: SyncedField(value: "ja", updatedAt: stampB, deviceID: deviceB),
+ engineMode: SyncedField(value: "local", updatedAt: stampB, deviceID: deviceB),
+ hasAcknowledgedCloudSharing: SyncedField(value: true, updatedAt: stampB, deviceID: deviceB),
+ uiLanguage: SyncedField(value: .chinese, updatedAt: stampB, deviceID: deviceB),
+ translationTargetLocaleId: SyncedField(value: "en", updatedAt: stampB, deviceID: deviceB),
+ handednessPreference: SyncedField(value: .right, updatedAt: stampB, deviceID: deviceB),
+ cursorDragNavigationEnabled: SyncedField(value: false, updatedAt: stampB, deviceID: deviceB),
+ polishIntensity: SyncedField(value: .light, updatedAt: stampB, deviceID: deviceB),
+ flowSkipAppSwitch: SyncedField(value: false, updatedAt: stampB, deviceID: deviceB),
+ flowInactivityDuration: SyncedField(value: .threeHours, updatedAt: stampB, deviceID: deviceB)
)
- let merged = SyncedAppSettings.merge(local: older, remote: newer)
- XCTAssertEqual(merged.model, "gpt-new")
- XCTAssertEqual(merged.localeId, "zh-Hans")
- XCTAssertEqual(merged.engineMode, "local")
+ let merged = SyncedAppSettingsV2.merge(local: local, remote: remote)
+
+ XCTAssertEqual(merged.baseURL.value, "https://remote.example")
+ XCTAssertEqual(merged.localeId.value, "ja")
+ XCTAssertEqual(merged.engineMode.value, "local")
}
- func testEnableSyncUploadsMergedSettingsAndToggle() async throws {
- store.setModeId("polish")
- store.setLocaleId("zh-Hans")
-
- let remote = SyncedAppSettings(
- updatedAt: Date().addingTimeInterval(3600),
- providerId: "openai",
- baseURL: "https://remote.example",
- model: "remote-model",
- modeId: "polish",
- localeId: "en",
- engineMode: "cloud",
- hasAcknowledgedCloudSharing: true,
- uiLanguage: .english,
- translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
- handednessPreference: .right,
- cursorDragNavigationEnabled: true,
- polishIntensity: .medium,
- flowSkipAppSwitch: true,
- flowInactivityDuration: .twelveHours
- )
- try settingsSync.push(remote)
-
- try await settingsSync.enableSync()
-
- XCTAssertTrue(store.settingsICloudSyncEnabled)
- XCTAssertEqual(kvs.object(forKey: ICloudSyncPreferences.settingsEnabledKey) as? Bool, true)
- XCTAssertEqual(settingsSync.loadRemote()?.localeId, "en")
- }
-
- func testPullAndMergeAppliesRemoteSettingsToAppGroup() async throws {
+ func testLegacyV1PullDoesNotClearKeychain() async throws {
+ try Keychain.setAPIKey("sk-local-openai", for: "openai", useICloudSync: false)
store.setSettingsICloudSyncEnabled(true)
- store.setLocaleId("auto")
- let remote = SyncedAppSettings(
- updatedAt: Date(timeIntervalSince1970: 900),
+ let legacy = SyncedAppSettings(
+ updatedAt: Date().addingTimeInterval(3600),
providerId: "openai",
baseURL: "https://remote.example",
model: "remote-model",
@@ -123,12 +105,44 @@ final class SettingsCloudSyncTests: XCTestCase {
flowSkipAppSwitch: true,
flowInactivityDuration: .twelveHours
)
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ let data = try encoder.encode(legacy)
+ kvs.set(data, forKey: SettingsCloudSync.legacyKVSKey)
+
+ await settingsSync.pullAndMerge(store: store)
+
+ XCTAssertEqual(Keychain.apiKey(for: "openai", preferICloudSync: false), "sk-local-openai")
+ XCTAssertEqual(store.localeId, "ja")
+ }
+
+ func testEnableSyncMigratesKeysToICloudKeychain() async throws {
+ try Keychain.setAPIKey("sk-local-openai", for: "openai", useICloudSync: false)
+
+ try await settingsSync.enableSync()
+
+ XCTAssertTrue(store.settingsICloudSyncEnabled)
+ XCTAssertEqual(Keychain.apiKey(for: "openai", preferICloudSync: true), "sk-local-openai")
+ }
+
+ func testPullAndMergeAppliesRemoteSettingsToAppGroup() async throws {
+ store.setSettingsICloudSyncEnabled(true)
+ store.setLocaleId("auto")
+
+ let deviceID = SyncDeviceID.current(defaults: defaults)
+ let stamp = Date(timeIntervalSince1970: 900)
+ var remote = SyncedAppSettingsV2.seeded(
+ from: AppGroupConfiguration.load(fromAvailable: defaults),
+ deviceID: deviceB,
+ updatedAt: stamp
+ )
+ remote.localeId = SyncedField(value: "ja", updatedAt: stamp, deviceID: deviceB)
try settingsSync.push(remote)
await settingsSync.pullAndMerge(store: store)
XCTAssertEqual(store.localeId, "ja")
- XCTAssertEqual(store.settingsCloudUpdatedAt?.timeIntervalSince1970, 900, accuracy: 1)
+ XCTAssertEqual(store.settingsCloudUpdatedAt?.timeIntervalSince1970 ?? 0, 900, accuracy: 1)
}
func testPushLocalIfEnabledSkipsWhenDisabled() async throws {
diff --git a/OSGKeyboardTests/SpeechHistoryCloudSyncTests.swift b/OSGKeyboardTests/SpeechHistoryCloudSyncTests.swift
new file mode 100644
index 0000000..22277bd
--- /dev/null
+++ b/OSGKeyboardTests/SpeechHistoryCloudSyncTests.swift
@@ -0,0 +1,138 @@
+// SpeechHistoryCloudSyncTests.swift
+// OSGKeyboardTests
+//
+// Hermetic tests for speech history iCloud merge, tombstones, and caps.
+
+import XCTest
+@testable import OSGKeyboardShared
+
+@MainActor
+final class SpeechHistoryCloudSyncTests: XCTestCase {
+
+ private var suiteName: String!
+ private var configDefaults: UserDefaults!
+ private var historyDefaults: UserDefaults!
+ private var store: AppGroupStore!
+ private var kvs: FakeUbiquitousKeyValueStore!
+ private var sync: SpeechHistoryCloudSync!
+
+ override func setUp() {
+ super.setUp()
+ suiteName = "group.com.osgkeyboard.shared.tests.history.\(UUID().uuidString)"
+ configDefaults = UserDefaults(suiteName: suiteName)!
+ configDefaults.removePersistentDomain(forName: suiteName)
+ historyDefaults = UserDefaults(suiteName: "\(suiteName).history")!
+ historyDefaults.removePersistentDomain(forName: "\(suiteName).history")
+ store = AppGroupStore(defaults: configDefaults)
+ store.setSettingsICloudSyncEnabled(true)
+ kvs = FakeUbiquitousKeyValueStore()
+ sync = SpeechHistoryCloudSync(kvs: kvs, makeStore: { [unowned self] in store }) { [unowned self] in
+ historyDefaults
+ }
+ }
+
+ override func tearDown() {
+ configDefaults.removePersistentDomain(forName: suiteName)
+ historyDefaults.removePersistentDomain(forName: "\(suiteName).history")
+ super.tearDown()
+ }
+
+ func testMergeUnionsDistinctEntriesByID() {
+ let idA = UUID()
+ let idB = UUID()
+ let local = SyncedSpeechHistory(
+ updatedAt: Date(timeIntervalSince1970: 100),
+ entries: [
+ SpeechHistoryEntry(id: idA, text: "local", createdAt: Date(timeIntervalSince1970: 10))
+ ]
+ )
+ let remote = SyncedSpeechHistory(
+ updatedAt: Date(timeIntervalSince1970: 200),
+ entries: [
+ SpeechHistoryEntry(id: idB, text: "remote", createdAt: Date(timeIntervalSince1970: 20))
+ ]
+ )
+
+ let merged = SyncedSpeechHistory.merge(local: local, remote: remote)
+
+ XCTAssertEqual(Set(merged.entries.map(\.id)), Set([idA, idB]))
+ XCTAssertEqual(merged.updatedAt, remote.updatedAt)
+ }
+
+ func testMergeAppliesDeletedEntryIDs() {
+ let id = UUID()
+ let local = SyncedSpeechHistory(
+ entries: [SpeechHistoryEntry(id: id, text: "gone", createdAt: Date())]
+ )
+ let remote = SyncedSpeechHistory(deletedEntryIDs: [id: Date()])
+
+ let merged = SyncedSpeechHistory.merge(local: local, remote: remote)
+
+ XCTAssertTrue(merged.entries.isEmpty)
+ XCTAssertNotNil(merged.deletedEntryIDs[id])
+ }
+
+ func testMergeAppliesClearedAt() {
+ let clearedAt = Date(timeIntervalSince1970: 500)
+ let local = SyncedSpeechHistory(
+ entries: [
+ SpeechHistoryEntry(text: "old", createdAt: Date(timeIntervalSince1970: 100)),
+ SpeechHistoryEntry(text: "new", createdAt: Date(timeIntervalSince1970: 600))
+ ]
+ )
+ let remote = SyncedSpeechHistory(clearedAt: clearedAt)
+
+ let merged = SyncedSpeechHistory.merge(local: local, remote: remote)
+
+ XCTAssertEqual(merged.entries.count, 1)
+ XCTAssertEqual(merged.entries.first?.text, "new")
+ }
+
+ func testMergeCapsAt300Entries() {
+ let localEntries = (0..<200).map { index in
+ SpeechHistoryEntry(
+ text: "local-\(index)",
+ createdAt: Date(timeIntervalSince1970: TimeInterval(index))
+ )
+ }
+ let remoteEntries = (0..<200).map { index in
+ SpeechHistoryEntry(
+ text: "remote-\(index)",
+ createdAt: Date(timeIntervalSince1970: TimeInterval(index) + 0.5)
+ )
+ }
+ let merged = SyncedSpeechHistory.merge(
+ local: SyncedSpeechHistory(entries: localEntries),
+ remote: SyncedSpeechHistory(entries: remoteEntries)
+ )
+
+ XCTAssertEqual(merged.entries.count, SyncedSpeechHistory.maxEntries)
+ }
+
+ func testPullAndMergeAppliesRemoteHistory() async throws {
+ let entry = SpeechHistoryEntry(text: "hello", createdAt: Date())
+ let remote = SyncedSpeechHistory(updatedAt: Date(), entries: [entry])
+ try sync.push(remote)
+
+ await sync.pullAndMerge(store: store)
+
+ let loaded = SpeechHistoryStorage.load(from: historyDefaults)
+ XCTAssertEqual(loaded.entries.map(\.text), ["hello"])
+ }
+
+ func testMergeAndPushUnionsLocalAndRemote() async throws {
+ let localEntry = SpeechHistoryEntry(text: "iphone", createdAt: Date(timeIntervalSince1970: 10))
+ SpeechHistoryStorage.save(
+ SyncedSpeechHistory(updatedAt: Date(), entries: [localEntry]),
+ to: historyDefaults
+ )
+ let remoteEntry = SpeechHistoryEntry(text: "mac", createdAt: Date(timeIntervalSince1970: 20))
+ try sync.push(SyncedSpeechHistory(updatedAt: Date(), entries: [remoteEntry]))
+
+ try await sync.mergeAndPushIfEnabled()
+
+ let loaded = SpeechHistoryStorage.load(from: historyDefaults)
+ XCTAssertEqual(Set(loaded.entries.map(\.text)), Set(["iphone", "mac"]))
+ XCTAssertEqual(sync.loadRemote()?.entries.count, 2)
+ }
+}
diff --git a/OSGKeyboardTests/UsageStatisticsCloudSyncTests.swift b/OSGKeyboardTests/UsageStatisticsCloudSyncTests.swift
new file mode 100644
index 0000000..ed72ee0
--- /dev/null
+++ b/OSGKeyboardTests/UsageStatisticsCloudSyncTests.swift
@@ -0,0 +1,134 @@
+// UsageStatisticsCloudSyncTests.swift
+// OSGKeyboardTests
+//
+// Hermetic tests for cumulative usage statistics iCloud merge.
+
+import XCTest
+@testable import OSGKeyboardShared
+
+@MainActor
+final class UsageStatisticsCloudSyncTests: XCTestCase {
+
+ private var suiteName: String!
+ private var defaults: UserDefaults!
+ private var store: AppGroupStore!
+ private var kvs: FakeUbiquitousKeyValueStore!
+ private var sync: UsageStatisticsCloudSync!
+ private let deviceA = "device-a"
+ private let deviceB = "device-b"
+
+ override func setUp() {
+ super.setUp()
+ suiteName = "group.com.osgkeyboard.shared.tests.usage.\(UUID().uuidString)"
+ defaults = UserDefaults(suiteName: suiteName)!
+ defaults.removePersistentDomain(forName: suiteName)
+ defaults.set(deviceA, forKey: "sync.deviceID.v1")
+ store = AppGroupStore(defaults: defaults)
+ store.setSettingsICloudSyncEnabled(true)
+ kvs = FakeUbiquitousKeyValueStore()
+ sync = UsageStatisticsCloudSync(kvs: kvs) { [unowned self] in store }
+ }
+
+ override func tearDown() {
+ defaults.removePersistentDomain(forName: suiteName)
+ super.tearDown()
+ }
+
+ func testGCounterMergeSumsAcrossDevices() {
+ let local = SyncedUsageStatisticsV2(devices: [
+ deviceA: UsageStatisticsDeviceSlice(
+ updatedAt: Date(timeIntervalSince1970: 100),
+ dictationDurationSeconds: 30,
+ dictationCharacterCount: 120,
+ translationCharacterCount: 10
+ ),
+ ])
+ let remote = SyncedUsageStatisticsV2(devices: [
+ deviceB: UsageStatisticsDeviceSlice(
+ updatedAt: Date(timeIntervalSince1970: 200),
+ dictationDurationSeconds: 45,
+ dictationCharacterCount: 80,
+ translationCharacterCount: 25
+ ),
+ ])
+
+ let merged = SyncedUsageStatisticsV2.merge(local: local, remote: remote).aggregated
+
+ XCTAssertEqual(merged.dictationDurationSeconds, 75)
+ XCTAssertEqual(merged.dictationCharacterCount, 200)
+ XCTAssertEqual(merged.translationCharacterCount, 35)
+ }
+
+ func testGCounterMergeTakesMaxForSameDevice() {
+ let local = SyncedUsageStatisticsV2(devices: [
+ deviceA: UsageStatisticsDeviceSlice(
+ updatedAt: Date(timeIntervalSince1970: 100),
+ dictationDurationSeconds: 30,
+ dictationCharacterCount: 120,
+ translationCharacterCount: 10
+ ),
+ ])
+ let remote = SyncedUsageStatisticsV2(devices: [
+ deviceA: UsageStatisticsDeviceSlice(
+ updatedAt: Date(timeIntervalSince1970: 200),
+ dictationDurationSeconds: 45,
+ dictationCharacterCount: 80,
+ translationCharacterCount: 25
+ ),
+ ])
+
+ let merged = SyncedUsageStatisticsV2.merge(local: local, remote: remote).aggregated
+
+ XCTAssertEqual(merged.dictationDurationSeconds, 45)
+ XCTAssertEqual(merged.dictationCharacterCount, 120)
+ XCTAssertEqual(merged.translationCharacterCount, 25)
+ }
+
+ func testPullAndMergeAppliesRemoteTotals() async throws {
+ let remote = SyncedUsageStatisticsV2(devices: [
+ deviceB: UsageStatisticsDeviceSlice(
+ updatedAt: Date(),
+ dictationDurationSeconds: 90,
+ dictationCharacterCount: 500,
+ translationCharacterCount: 40
+ ),
+ ])
+ try sync.push(remote)
+
+ await sync.pullAndMerge(store: store)
+
+ let loaded = SyncedUsageStatisticsStorage.load(from: defaults).aggregated
+ XCTAssertEqual(loaded.dictationDurationSeconds, 90)
+ XCTAssertEqual(loaded.dictationCharacterCount, 500)
+ XCTAssertEqual(loaded.translationCharacterCount, 40)
+ }
+
+ func testPullUnionsIndependentDeviceTotals() async throws {
+ SyncedUsageStatisticsStorage.upsertCurrentDeviceSlice(
+ UsageStatisticsDeviceSlice(
+ updatedAt: Date(),
+ dictationDurationSeconds: 10,
+ dictationCharacterCount: 300,
+ translationCharacterCount: 0
+ ),
+ defaults: defaults,
+ deviceID: deviceA
+ )
+ let remote = SyncedUsageStatisticsV2(devices: [
+ deviceB: UsageStatisticsDeviceSlice(
+ updatedAt: Date(),
+ dictationDurationSeconds: 20,
+ dictationCharacterCount: 150,
+ translationCharacterCount: 5
+ ),
+ ])
+ try sync.push(remote)
+
+ await sync.pullAndMerge(store: store)
+
+ let loaded = SyncedUsageStatisticsStorage.load(from: defaults).aggregated
+ XCTAssertEqual(loaded.dictationDurationSeconds, 30)
+ XCTAssertEqual(loaded.dictationCharacterCount, 450)
+ XCTAssertEqual(loaded.translationCharacterCount, 5)
+ }
+}
diff --git a/docs/privacy.html b/docs/privacy.html
index 021f1f4..412f28a 100644
--- a/docs/privacy.html
+++ b/docs/privacy.html
@@ -15,18 +15,18 @@
中文
OSGKeyboard Privacy Policy
- Last updated: July 5, 2026 · v0.3.6
+ Last updated: July 8, 2026 · v0.5.x
OSGKeyboard is a custom iOS keyboard that turns your voice into text. It runs as a Custom Keyboard Extension on iOS 26 and later, and uses Apple's on-device SpeechAnalyzer + DictationTranscriber for transcription. After transcription, text may be polished or translated via a cloud LLM. This policy explains what data the app processes and how it is used.
What we collect
Voice audio — captured only while you actively record. Audio is transcribed on-device with Apple's SpeechAnalyzer + DictationTranscriber; raw audio is not uploaded by OSGKeyboard.
Transcribed text — after on-device ASR, the transcript (not audio) is sent for polish. On the local engine , polish uses a built-in DeepSeek endpoint configured at build time. On the cloud engine , polish and optional translation use the OpenAI-compatible API you configure (e.g. OpenAI, Qwen DashScope, Moonshot, Zhipu, Xiaomi MiMo, or your own server).
- API credentials — your cloud-engine LLM API key is stored in the iOS Keychain on your device and is read only when an LLM request is made. It is shared with the main app through a shared Keychain group, never through UserDefaults.
- App preferences — engine mode, recognition language, polish intensity, translation target, handedness, cursor-navigation toggle, and keyboard settings are stored in App Group UserDefaults on your device so the main app and keyboard extension stay in sync.
- Personal dictionary — terms and aliases you add in the Dictionary tab are stored locally on your device. They are included in LLM polish prompts so your vocabulary is preserved; dictionary data is not uploaded to a separate server.
- Voice history — the host app may keep a local list of recent successful transcripts in its History tab, on-device only, capped at 500 entries.
- Usage statistics — cumulative dictation time, dictation characters, translation characters, and dictionary entry count are computed and stored locally on the home screen stats card.
+ API credentials — your cloud-engine LLM API key is stored in the iOS Keychain on your device and is read only when an LLM request is made. It is shared with the main app through a shared Keychain group, never through UserDefaults. When you enable iCloud settings sync , API keys replicate through Apple's iCloud Keychain to your other signed-in devices — not through iCloud Key-Value Store JSON.
+ App preferences — engine mode, recognition language, polish intensity, translation target, handedness, cursor-navigation toggle, and keyboard settings are stored in App Group UserDefaults on your device so the main app and keyboard extension stay in sync. When iCloud settings sync is enabled, these preferences (excluding API keys) may also be mirrored in your private iCloud Key-Value Store account.
+ Personal dictionary — terms and aliases you add in the Dictionary tab are stored locally on your device. They are included in LLM polish prompts so your vocabulary is preserved. Optional iCloud dictionary sync mirrors your dictionary through your private iCloud Key-Value Store; OSGKeyboard does not operate a separate dictionary server.
+ Voice history — the host app may keep a list of recent successful transcripts in its History tab. History is capped at 300 entries. When iCloud settings sync is enabled, history may also sync through your private iCloud Key-Value Store.
+ Usage statistics — cumulative dictation time, dictation characters, and translation characters are computed for the home screen stats card. When iCloud settings sync is enabled, per-device totals may merge through your private iCloud Key-Value Store.
What we do not collect
@@ -51,7 +51,7 @@
After on-device ASR, transcribed text is sent for polish and optional translation. On the local engine this goes to a built-in DeepSeek endpoint. On the cloud engine it goes to the OpenAI-compatible API endpoint you configured in Settings. That provider's privacy policy applies to those requests. OSGKeyboard does not proxy, log, or aggregate your requests.
Data retention
- 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. Voice history is kept locally (up to 500 entries) and is never uploaded; you can clear it at any time from the History tab or by resetting settings.
+ Settings remain on your device until you delete the app or reset settings. When iCloud settings sync is enabled, API keys replicate through iCloud Keychain and preferences, statistics, and history may sync through your private iCloud account. Transcription results are passed to the host app you are typing in and are not stored long-term by OSGKeyboard. Voice history is capped at 300 entries; you can clear it from the History tab or by resetting settings.
Children's privacy
OSGKeyboard is not directed to children under 13 and does not knowingly collect personal data from children.
@@ -67,18 +67,18 @@
OSGKeyboard 隐私政策
- 更新日期: 2026 年 7 月 5 日 · v0.3.6
+ 更新日期: 2026 年 7 月 8 日 · v0.5.x
OSGKeyboard 是一款 iOS 自定义键盘,可将语音转为文字。它以自定义键盘扩展的形式运行,需要 iOS 26 及以上系统,转写全程使用 Apple 端侧的 SpeechAnalyzer + DictationTranscriber。转写完成后,文字可能经云端 LLM 润色或翻译。本政策说明应用处理哪些数据及用途。
我们处理的数据
语音音频 — 仅在你主动录音时采集。音频在设备端通过 SpeechAnalyzer + DictationTranscriber 转写,OSGKeyboard 不会上传原始录音。
转写文字 — 端侧 ASR 完成后,转写文字(非音频)会发送润色。本地引擎 使用构建时配置的内置 DeepSeek 端点;云端引擎 的润色与可选翻译使用你配置的 OpenAI 兼容 API(OpenAI / 通义 DashScope / Moonshot / 智谱 / 小米 MiMo / 自建服务等)。
- API 凭证 — 云端引擎的 LLM API Key 保存在设备 Keychain,仅在发起 LLM 请求时读取;通过共享 Keychain 组与主 App 共享,不会 写入 UserDefaults。
- 应用偏好 — 引擎模式、识别语言、润色档位、翻译目标、握持偏好、光标导航开关、键盘设置等保存在 App Group UserDefaults,仅用于主 App 与键盘扩展之间的状态同步。
- 个性词库 — 你在「词库」Tab 添加的词条与别名保存在本机。润色时会写入 LLM 提示词以保留你的词汇;词库数据不会上传到独立服务器。
- 语音历史 — 主 App 可在「历史」页保留近期成功转写,最多 500 条,仅本机保存,不会上传。
- 用量统计 — 首页统计卡片的累计听写时长、听写字数、翻译字数、词库词条数均在本地计算与保存。
+ API 凭证 — 云端引擎的 LLM API Key 保存在设备 Keychain,仅在发起 LLM 请求时读取;通过共享 Keychain 组与主 App 共享,不会 写入 UserDefaults。开启iCloud 设置同步 后,API 密钥经 Apple iCloud 钥匙串 同步到你其他已登录设备,不会 写入 iCloud 键值存储 JSON。
+ 应用偏好 — 引擎模式、识别语言、润色档位、翻译目标、握持偏好、光标导航开关、键盘设置等保存在 App Group UserDefaults,用于主 App 与键盘扩展之间的状态同步。开启 iCloud 设置同步后,这些偏好(不含 API 密钥)也可能镜像到你私有的 iCloud 键值存储账户。
+ 个性词库 — 你在「词库」Tab 添加的词条与别名保存在本机,润色时会写入 LLM 提示词。可选的iCloud 词库同步 经私有 iCloud 键值存储在多设备间镜像;OSGKeyboard 不运营独立词库服务器。
+ 语音历史 — 主 App 可在「历史」页保留近期成功转写,上限 300 条。开启 iCloud 设置同步后,历史也可能经私有 iCloud 键值存储同步。
+ 用量统计 — 首页统计卡片的累计听写时长、听写字数、翻译字数在本地计算。开启 iCloud 设置同步后,各设备分量可能经私有 iCloud 键值存储合并。
我们不收集的内容
@@ -103,7 +103,7 @@
端侧 ASR 完成后,转写文字会发送润色与可选翻译。本地引擎发送至内置 DeepSeek 端点;云端引擎发送至你在设置中配置的 OpenAI 兼容 API 端点。该服务商的隐私政策适用于相关请求。OSGKeyboard 不代理、不记录、不聚合这些请求。
数据保留
- 设置与 API Key 保留在设备上,直至卸载或重置。识别结果写入你正在使用的宿主 App,OSGKeyboard 不会长期存储。语音历史最多 500 条本机保存,不会上传,可随时在「历史」页清空或通过重置设置清除。
+ 设置保留在设备上,直至卸载或重置。开启 iCloud 设置同步后,API 密钥经 iCloud 钥匙串同步,偏好、统计与历史可能经私有 iCloud 账户同步。识别结果写入你正在使用的宿主 App,OSGKeyboard 不会长期存储。语音历史上限 300 条,可随时在「历史」页清空或通过重置设置清除。
儿童隐私
OSGKeyboard 不面向 13 岁以下儿童,亦不会明知地从儿童处收集个人信息。
diff --git a/project.yml b/project.yml
index 383520f..d2b7d39 100644
--- a/project.yml
+++ b/project.yml
@@ -26,6 +26,14 @@ options:
# Carthage (matches the long-standing "zero dependencies" promise in the
# README). Optional post-ASR cloud polish routes through DeepSeek (or
# any OpenAI-compatible endpoint) using the existing `LLMClient`.
+#
+# macOS only: `mlx-swift-asr` (MLXASR) for Qwen3-ASR-1.7B on-device ASR.
+# iOS targets remain zero-SPM.
+
+packages:
+ MLXSwiftASR:
+ url: https://github.com/ontypehq/mlx-swift-asr
+ branch: main
settings:
base:
@@ -375,6 +383,91 @@ targets:
PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.ext.tests
TARGETED_DEVICE_FAMILY: "1"
+ # =========================================================
+ # macOS 菜单栏 App (Phase 1 · 云端 MVP)
+ # =========================================================
+ # Standalone menu-bar utility: record → cloud ASR → polish → clipboard.
+ # Reuses the platform-agnostic core files from OSGKeyboardShared at the
+ # source level (no framework), excluding the iOS-only files that import
+ # SpeechAnalyzer / AVAudioSession / UIKit / SwiftUI views. Local mode uses
+ # Qwen3-ASR MLX via mlx-swift-asr (requires macOS 15+, Apple Silicon).
+ OSGKeyboardMac:
+ type: application
+ platform: macOS
+ deploymentTarget: "15.0"
+ sources:
+ # Same Icon Composer asset as the iOS app — do not ship a separate PNG
+ # appiconset; actool crashes if both coexist.
+ - path: OSGKeyboard/AppIcon.icon
+ buildPhase: resources
+ # Reuse the iOS asset catalog for provider logos + brand mark. Exclude
+ # the PNG appiconset so it doesn't clash with AppIcon.icon above.
+ - path: OSGKeyboard/Assets.xcassets
+ excludes:
+ - "AppIcon.appiconset"
+ - path: OSGKeyboardMac
+ - path: OSGKeyboardShared
+ excludes:
+ - "en.lproj"
+ - "zh-Hans.lproj"
+ - "Info.plist"
+ - "**/*.swift.example"
+ - "DesignSystem/WaveformView.swift"
+ - "DesignSystem/TranslationChip.swift"
+ - "DesignSystem/RecordButton.swift"
+ - "Services/ASRService.swift"
+ - "Services/CloudASR/CloudASRService.swift"
+ - "Services/ChunkedUtterancePipeline.swift"
+ - "Services/FlowContinuousCapture.swift"
+ - "Services/LiveDictationController.swift"
+ - "Services/KeyboardState.swift"
+ - "Services/CursorNavigation.swift"
+ - "Services/CustomLanguageModelManager.swift"
+ - "Models/MicVoiceAvailability+Keyboard.swift"
+ - "Utilities/ProgressiveDictationTranscriptAccumulator.swift"
+ - path: OSGKeyboardShared/en.lproj/Shared.strings
+ buildPhase: resources
+ - path: OSGKeyboardShared/zh-Hans.lproj/Shared.strings
+ buildPhase: resources
+ entitlements:
+ path: OSGKeyboardMac/OSGKeyboardMac.entitlements
+ properties:
+ com.apple.security.app-sandbox: true
+ com.apple.security.device.audio-input: true
+ com.apple.security.network.client: true
+ com.apple.developer.ubiquity-kvstore-identifier: $(TeamIdentifierPrefix)com.osgkeyboard.ios
+ keychain-access-groups:
+ - $(AppIdentifierPrefix)com.osgkeyboard.shared
+ info:
+ path: OSGKeyboardMac/Info.plist
+ properties:
+ CFBundleDisplayName: OSGKeyboard
+ CFBundleShortVersionString: "$(MARKETING_VERSION)"
+ CFBundleVersion: "$(CURRENT_PROJECT_VERSION)"
+ CFBundleDevelopmentRegion: en
+ CFBundleLocalizations:
+ - en
+ - zh-Hans
+ LSApplicationCategoryType: public.app-category.utilities
+ NSMicrophoneUsageDescription: "OSGKeyboard uses the microphone to transcribe your voice via your configured cloud speech provider."
+ NSSpeechRecognitionUsageDescription: "OSGKeyboard uses on-device speech recognition for local dictation mode."
+ NSHumanReadableCopyright: "OSGKeyboard"
+ ITSAppUsesNonExemptEncryption: false
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.mac
+ MACOSX_DEPLOYMENT_TARGET: "15.0"
+ ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
+ # macOS app is not an app extension; override the framework-wide flag.
+ APPLICATION_EXTENSION_API_ONLY: NO
+ CODE_SIGN_STYLE: Automatic
+ DEVELOPMENT_TEAM: X329MZU23S
+ dependencies:
+ - sdk: Speech.framework
+ - sdk: AVFoundation.framework
+ - package: MLXSwiftASR
+ product: MLXASR
+
schemes:
OSGKeyboard:
build:
@@ -391,3 +484,12 @@ schemes:
- OSGKeyboardExtTests
archive:
config: Release
+
+ OSGKeyboardMac:
+ build:
+ targets:
+ OSGKeyboardMac: all
+ run:
+ config: Debug
+ archive:
+ config: Release
From 200265fbd60d9f7607331ec0a19af6857f60cc1b Mon Sep 17 00:00:00 2001
From: Rocky <72559939+hkgood@users.noreply.github.com>
Date: Thu, 9 Jul 2026 08:55:37 +0800
Subject: [PATCH 2/3] feat(macos): local ASR model manager, menu-bar polish,
and release 0.5.2
Adds a bundled local ASR model catalog for the macOS app with one-click
Sherpa Qwen3 / SenseVoice downloads (pause/resume, inline actions) and a
shared model storage directory used by MLX Qwen3. Fixes the light-mode
sidebar material and makes the menu-bar icon follow the system appearance
with a refreshed status mark. Renames the built product to OSGKeyboard.app.
Bumps version to 0.5.2 (build 19).
---
CHANGELOG.md | 9 +
.../OSGStatusMark.imageset/Contents.json | 25 +
.../OSGStatusMark.imageset/OSGStatusMark.png | Bin 0 -> 4029 bytes
.../Services/FlowLiveActivityController.swift | 39 +-
OSGKeyboard/Services/FlowSessionManager.swift | 482 ++++++++++++---
OSGKeyboard/Views/FlowColdStartOverlay.swift | 29 +-
.../Services/KeyboardFlowCoordinator.swift | 196 +++++-
.../FlowLiveActivityWidget.swift | 9 +-
OSGKeyboardMac/DashboardView.swift | 61 +-
OSGKeyboardMac/MacDictationPipeline.swift | 49 +-
OSGKeyboardMac/MacDictationViewModel.swift | 38 +-
.../MacLocalASRModelSettingsView.swift | 455 ++++++++++++++
OSGKeyboardMac/MacLocalASRService.swift | 138 ++++-
OSGKeyboardMac/MacQwen3ASREngine.swift | 5 +-
OSGKeyboardMac/MacQwen3LocalASR.swift | 8 +-
OSGKeyboardMac/MacRootView.swift | 1 -
OSGKeyboardMac/MacSettingsView.swift | 54 +-
OSGKeyboardMac/MacSherpaLocalASR.swift | 56 ++
OSGKeyboardMac/MacSherpaONNXRunner.swift | 153 +++++
OSGKeyboardMac/OSGKeyboardMac.entitlements | 2 +-
OSGKeyboardMac/OSGKeyboardMacApp.swift | 57 +-
.../Models/LocalASRBiasPayload.swift | 99 +++
.../Models/LocalASRCapabilities.swift | 82 +++
.../Models/LocalASRModelCatalog.swift | 134 ++++
.../Models/PersonalDictionary+ASRBias.swift | 25 +
OSGKeyboardShared/Models/PolishContext.swift | 6 +
OSGKeyboardShared/Models/ProviderConfig.swift | 2 +-
.../Resources/LocalASR/local-asr-catalog.json | 102 +++
.../Services/AppGroupStore.swift | 1 +
.../Services/BuiltinLexiconIndex.swift | 152 +++++
.../Services/FlowContinuousCapture.swift | 29 +-
.../Services/FlowSessionBridge.swift | 276 ++++++++-
.../Services/FlowSessionDarwin.swift | 12 +
.../Services/FlowSessionKeys.swift | 6 +-
.../Services/LocalASRBiasAdapter.swift | 159 +++++
.../LocalASRBiasDiagnosticsStore.swift | 56 ++
.../LocalASRInstalledManifestIO.swift | 32 +
.../LocalASRModelDownloadClient.swift | 157 +++++
.../Services/LocalASRModelInstallState.swift | 138 +++++
.../Services/LocalASRModelManager.swift | 492 +++++++++++++++
.../Services/LocalASRPreferenceKeys.swift | 8 +
.../LocalASRTranscriptCorrector.swift | 68 ++
.../Services/PolishingService.swift | 16 +-
OSGKeyboardShared/en.lproj/Shared.strings | 44 +-
.../zh-Hans.lproj/Shared.strings | 44 +-
OSGKeyboardTests/FlowSessionBridgeTests.swift | 109 ++++
.../LocalASRBiasAdapterTests.swift | 124 ++++
.../LocalASRModelCatalogTests.swift | 88 +++
docs/local-asr-architecture.md | 585 ++++++++++++++++++
project.yml | 20 +-
50 files changed, 4666 insertions(+), 266 deletions(-)
create mode 100644 OSGKeyboard/Assets.xcassets/OSGStatusMark.imageset/Contents.json
create mode 100644 OSGKeyboard/Assets.xcassets/OSGStatusMark.imageset/OSGStatusMark.png
create mode 100644 OSGKeyboardMac/MacLocalASRModelSettingsView.swift
create mode 100644 OSGKeyboardMac/MacSherpaLocalASR.swift
create mode 100644 OSGKeyboardMac/MacSherpaONNXRunner.swift
create mode 100644 OSGKeyboardShared/Models/LocalASRBiasPayload.swift
create mode 100644 OSGKeyboardShared/Models/LocalASRCapabilities.swift
create mode 100644 OSGKeyboardShared/Models/LocalASRModelCatalog.swift
create mode 100644 OSGKeyboardShared/Resources/LocalASR/local-asr-catalog.json
create mode 100644 OSGKeyboardShared/Services/BuiltinLexiconIndex.swift
create mode 100644 OSGKeyboardShared/Services/LocalASRBiasAdapter.swift
create mode 100644 OSGKeyboardShared/Services/LocalASRBiasDiagnosticsStore.swift
create mode 100644 OSGKeyboardShared/Services/LocalASRInstalledManifestIO.swift
create mode 100644 OSGKeyboardShared/Services/LocalASRModelDownloadClient.swift
create mode 100644 OSGKeyboardShared/Services/LocalASRModelInstallState.swift
create mode 100644 OSGKeyboardShared/Services/LocalASRModelManager.swift
create mode 100644 OSGKeyboardShared/Services/LocalASRPreferenceKeys.swift
create mode 100644 OSGKeyboardShared/Services/LocalASRTranscriptCorrector.swift
create mode 100644 OSGKeyboardTests/LocalASRBiasAdapterTests.swift
create mode 100644 OSGKeyboardTests/LocalASRModelCatalogTests.swift
create mode 100644 docs/local-asr-architecture.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2703ab3..637796a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,20 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [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
diff --git a/OSGKeyboard/Assets.xcassets/OSGStatusMark.imageset/Contents.json b/OSGKeyboard/Assets.xcassets/OSGStatusMark.imageset/Contents.json
new file mode 100644
index 0000000..f008e7a
--- /dev/null
+++ b/OSGKeyboard/Assets.xcassets/OSGStatusMark.imageset/Contents.json
@@ -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"
+ }
+}
diff --git a/OSGKeyboard/Assets.xcassets/OSGStatusMark.imageset/OSGStatusMark.png b/OSGKeyboard/Assets.xcassets/OSGStatusMark.imageset/OSGStatusMark.png
new file mode 100644
index 0000000000000000000000000000000000000000..b99bb95846cdef905cd8552c6d0e5972816abce4
GIT binary patch
literal 4029
zcmZu!cT`i^yS;RV#1JK#fT09Int&lP5@|wcQUXL^fT0&r!eFFCngS+-rWgo}p!6~}
zKm=5pH0eZwGYZ5JOpvBTh@c{f@-FN5-g@h;_5Qi*tnZ$4zJ2z$&;HiQbRpXAkx`HV
z0ASB4J1Y_Zh%&(OcWFuRyK&du831JIr>rd8;)*{`4^!X#3YTy((%9J<9qwJb;1C`g
z-R~ahC@nXTGmsK1xYew$EAwMfsl#V7mKSf-~V@=HkqRCxi$WJB}ew?;z&cq28%QGZM
zZ+S-*xnfk2GpvwfO`OfmjhzcLUEWbc3QT*(dc6H~qKIc6T)Q)AX|s^oCP~#0lWMUu
zmlG7%zHhCZQ+sylV%>-AsO#}gl75s6opJ(&+K&%C7OMY#>ieVPZjA14-j|eH2ey)X
ztv4MS$c+Qvy$e{rD^<(4vRhqgUv|3t;R0XJ1^xO9=NAlcUzNZD08$8LDcu`?NbE
z`Zn0mv3t!w&F{tqi4tY^H+@RCYAr3gxddwZzQtQTBYy}{{(K`a5WK(r;U*U6i6KeCM&rgsiQ+Gs
z{?XBR!C_&w>y&Y{0@V;+euUH!p4C1>bJM7OU)~hnw!|O9WvIMbKlV?LjL}}}Q6|am
zyw_xYk%ij1()(U8x9-d%_Bo9(%Is+KV>YDH{!`L(3e%D($u++b8h4<<nKsxR1kRd^zRJAv!!s~maq%W&cl|D680G5A)y!7TceHgRKpuf7vx(~^D5Am
zF%?npyC_E6Q8)f+Ar{1=?d%Syxy612clJHsSJRr#QyW;(^UPzh0iVzi16ZQSm{KYKlN*vS1bP{6sgc^ew=m3y2;$N
z)S@ru6Ivp@oV{Ja-S4@`@E_fohZ}h2rE>dS#`&f-OncS(;fz6Xj^nD+{hdX*A??|K
z%s9vK*IypJXBjx^rQ@LeF1$uIi3j^wyB{BY%C-EX(7g
zYvA?SEo4nX2(|`2rpcf}0-<8puB!e?7z&Dm*&qoPf92V=r`cnQtaZ}Y#FcFh5NhBv
zEIDe;)gD8_BI7Np7kh|N_;!C|yAGLKkAWUZuga?Mg==RXJF2P(sSO(FE=OP0WQamm
zH+?bN2i|Wm3RpmO8Z?--aR}L
zSwWJH9!3|%eDpU{Cqzghj31JvCu2q}9t3F?y2*t;fm7vb&4pe9$tc)pwx;qLX
zT@N~oq=%~_8l?w;`%{K=a^xZ}5~P)Hicek7H}LGf9rEo+@6jv@l$q;GXepu@_mAsI
zcW7u(N=T9{E|%V<3atRw7RAfc4&qz9p%St)#<%2Z;GNmbY%qR$3M;PC*3{2lknmym
z6a;Bf++HTyc1L1fs#Znxid=J1O3~fS
zXGbR4NG7cH+G?5aCy2>z8JMWRaInh(gK!|g+}WRrAhZnRhyn8^It)oA_qwg(Wt;(^
z1#%N$YkJ(GV>|cN6SOAS)S4&2nY|ocK=VBrdrVlaUsed%J)Dpn1A7szCNlR1~?*iqfmKaZEC(ldkk)R^L
z**Yx-WuVBJNQD7*wpekX{L$AZsI5=BK=*s|O+^9#n1Ptx=ED>zc|p>^Wel8Ss%w)
zO#`N`1lfh^Tt2w8k&&_poYVSm%0MLFAoU_CW#5i;${YW0@P8=%4^p0fWBSBR5MQ@c
zKRo5$w!02xOuruvq)fbCRU~xX2LMp*MoVl+KIb=}x*A0)$&&=6z#QY(Z@7|9Lq+Z^
zIa;j$p7^aCq-)&!`|r@Fx*~vJ`8P6nqYzy3`Jh*-k)PMBi7z9x}k28n?h0p-ej}i{F2WeUXR-uEiu|)1i0jg1_
zjFoDfPufK`m5MYnV$u%vyAv2f=qN
z2lM%zgiOlIZC0FMrR(wIaV-{Qj5}>vmdm$-jR{Z+0m2z>G0Fg-^G_RShB5m;Oj~NM
zf>vP=$EnA>RJ$J%6MmQrI*eF*+R}_X>c$N?5R7BYsO75$TRi{xQ6$BXQ)p&tIuhFlX45b1q1)tm;
z36F=rOVgrwrw!2WSO%Yw@8t}}2M!SUX&ZBNU+cu4Osa`+q=Ab~7#tt3%5$YX^ACI$
zSzqs8#tfu9x%o-U3s1*FaRyl)G8h&gFFZ&v()hbRkUWBEwK|e!ATA5G!}AGlmFV91
zy_4tqg0GZ0z9->!0mnJx=^Qq!u#mxO%PAD%skwz
zz3FR`B_mDo+?VtJ$)(1+Qv+}Gyh+o<9P79f5K{{EQEmT@zFw?c7`oY%@Fj<@JgP4q
zb2LFXg;&$Afv-Sbq*WrGOaSxFIdHHh9zfhSz5gQmR8Az~KO&oeNmoL@L#
zLg3m`FR}v3J$i8QmsM!(j5h@p2=7HJWc|9;5k{RvhYMxA_R+uZIM`#Y_FDAsmedla
z)R&VD!y)da2GDciOP?G;_Jq;LKY$NkV+(nRT70C|oA;&iJnoabQmjjF<7A4;dh0v9tB8--c>TnC53{JwlY=SQ)6kIpx1
z1rHHM9RZ&O)_yC#<_i*>h+LX3aSep@$aM0jYZY$x^~O8DYr!7Ev$u{!L|6JtZ_iMD
zH^QC|WhP#jsa?F0xPB}C;ggS14b#6QV}yy^OW;!_qxSf^*SwiYn4ci@Uvxy`tStSK
z?8KI7Q}pcR)&M^ecqO`vv1Cow@P!QllDw}
z^}HWV;{R-8Y}R$Af5t#$eXdT@&6LRr^>*@^NvpoMU#m|0=djW=eDZ_oW@hC0x+VFd
zhh{!vl@CNNv(k`0#nE)L1Ix#iqRXxnoO;zS+`H5Z_ZHyV~`
zcV1e2=og)r8y}*khc9$Epclp8+Pj6Vec?S90>uwo!v>f%m`@*;{!?i=N;WF$dhtsY
zsf{6vFukQX%jBz%T8`uKHpFliEqE?K1$j}gVu!u;P9DGMVDnX7s%*GdP4BaJyTsds
zcQSYpe84`W*`MJ(PdZT+#FH>o-?bskmRR=*RZ;LC@9=TWE-zUTavFP1d@e=or<9zQ
W?$W2Gs15L04xF+kTGd+yT=_5JAU&c0
literal 0
HcmV?d00001
diff --git a/OSGKeyboard/Services/FlowLiveActivityController.swift b/OSGKeyboard/Services/FlowLiveActivityController.swift
index 8c204ca..a173f26 100644
--- a/OSGKeyboard/Services/FlowLiveActivityController.swift
+++ b/OSGKeyboard/Services/FlowLiveActivityController.swift
@@ -10,12 +10,16 @@ import OSGKeyboardShared
enum FlowLiveActivityController {
nonisolated(unsafe) private static var currentActivity: Activity?
+ /// 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.activities
diff --git a/OSGKeyboard/Services/FlowSessionManager.swift b/OSGKeyboard/Services/FlowSessionManager.swift
index 269d424..cc7539d 100644
--- a/OSGKeyboard/Services/FlowSessionManager.swift
+++ b/OSGKeyboard/Services/FlowSessionManager.swift
@@ -25,9 +25,7 @@ final class FlowSessionManager: ObservableObject {
private let capture = FlowContinuousCapture()
private let store = AppGroupStore()
/// Cloud-engine polish; local engine runs through built-in DeepSeek polish.
- private var polisher: PolishingService {
- PolishingService()
- }
+ private let polisher = PolishingService()
/// Cached ASR instance. v0.2.0: the only on-device backend is iOS
/// `SpeechAnalyzer`, which has no warm-up step — we can hand the
/// factory-built service straight back without going through the
@@ -49,17 +47,26 @@ final class FlowSessionManager: ObservableObject {
private var expiryTask: Task?
private var levelTask: Task?
private var startTask: Task?
+ private var commandObserver: FlowSessionDarwinObserver?
/// Last recording state the poll loop observed — logs only on transition.
private var lastObservedRecordingState: FlowSessionKeys.RecordingState = .idle
+ private var activeSessionId: UUID?
+ private var currentUtteranceId: UUID?
+ private var currentCommandSeq: Int64 = 0
+ private var lastHandledCommandSeq: Int64 = 0
private var isUtteranceRecording = false
/// True from `stopped` until the result/error is written back to App Group.
private var isUtteranceProcessing = false
private var finalizeTask: Task?
private var asrTask: Task?
+ private var utteranceSafetyTask: Task?
private var chunkedPipeline: ChunkedUtterancePipeline?
private var currentPartial = ""
private var lastFinal = ""
private var chunkWarnings: [String] = []
+ private var lastReadyTraceSignature = ""
+ private var lastCommandFingerprint = ""
+ private var lastIgnoredCommandSignature = ""
/// Wall-clock span of the current mic-open utterance (excludes LLM polish).
private var utteranceRecordingStartedAt: Date?
/// True while the host app scene is `.active` — drives foreground renewal.
@@ -67,7 +74,11 @@ final class FlowSessionManager: ObservableObject {
private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
/// True while handling a keyboard-initiated `startflow` cold start.
private var isColdStartHandoff = false
- private static let coldStartAudioProofTimeout: TimeInterval = 2.5
+ private var coldStartRecoveryTask: Task?
+ /// Initial proof window — cold mic sessions often need >2.5s after app switch.
+ private static let coldStartAudioProofTimeout: TimeInterval = 6
+ /// Extra window after the first timeout while the overlay shows a failure hint.
+ private static let coldStartRecoveryProofTimeout: TimeInterval = 12
init() {
// Sessions are (re)started explicitly on app foreground via
@@ -83,6 +94,10 @@ final class FlowSessionManager: ObservableObject {
/// Starts a Flow session: permissions → continuous capture → App Group active.
func startSession(duration: TimeInterval? = nil, coldStart: Bool = false) {
+ traceState(
+ "startSession.request",
+ extra: "coldStart=\(coldStart) duration=\(Int(duration ?? FlowSessionPolicy.sessionDuration()))"
+ )
guard AppGroup.isAvailable else {
debug("cannot start flow session: App Group unavailable")
return
@@ -106,7 +121,10 @@ final class FlowSessionManager: ObservableObject {
return
}
- guard !isStarting else { return }
+ guard !isStarting else {
+ traceState("startSession.ignored", extra: "reason=alreadyStarting")
+ return
+ }
startTask?.cancel()
startTask = Task { @MainActor [weak self] in
@@ -148,6 +166,14 @@ final class FlowSessionManager: ObservableObject {
/// behind (its `endSession()` could not run at kill time).
func activateOnForeground() {
guard AppGroup.isAvailable else { return }
+ // Sweep any Live Activity a previous (force-quit) process left behind
+ // *before* we try to (re)start a session. Doing it here — rather than
+ // only inside `startSession()`'s success path — means a start that
+ // later fails (e.g. mic proof timeout) still clears the stale island
+ // instead of leaving a zombie on the lock screen / Dynamic Island.
+ // No-op when this process already owns a healthy Live Activity.
+ FlowLiveActivityController.clearOrphanedActivities()
+
guard AppPermissions.flowRequirementsMet else {
sessionWarning = permissionWarningMessage()
FlowSessionBridge.setHostReady(false)
@@ -161,7 +187,8 @@ final class FlowSessionManager: ObservableObject {
}
func dismissColdStartOverlay() {
- guard coldStartContext?.state != .preparing else { return }
+ coldStartRecoveryTask?.cancel()
+ coldStartRecoveryTask = nil
if isActive {
refreshHostReady()
}
@@ -189,8 +216,11 @@ final class FlowSessionManager: ObservableObject {
coldStartContext = nil
isColdStartHandoff = false
+ coldStartRecoveryTask?.cancel()
+ coldStartRecoveryTask = nil
startTask?.cancel()
startTask = nil
+ commandObserver = nil
pollingTask?.cancel()
pollingTask = nil
heartbeatTask?.cancel()
@@ -201,6 +231,8 @@ final class FlowSessionManager: ObservableObject {
levelTask = nil
finalizeTask?.cancel()
finalizeTask = nil
+ utteranceSafetyTask?.cancel()
+ utteranceSafetyTask = nil
if isUtteranceRecording || isUtteranceProcessing {
capture.cancelUtterance()
@@ -210,6 +242,10 @@ final class FlowSessionManager: ObservableObject {
}
asrTask = nil
chunkedPipeline = nil
+ activeSessionId = nil
+ currentUtteranceId = nil
+ currentCommandSeq = 0
+ lastHandledCommandSeq = 0
isUtteranceRecording = false
isUtteranceProcessing = false
@@ -305,9 +341,11 @@ final class FlowSessionManager: ObservableObject {
guard isActive else { return }
if capture.running {
- capture.reassertIfRunning()
- if capture.engineHasRecentAudio() {
+ let reasserted = capture.reassertIfRunning()
+ if reasserted, capture.engineHasRecentAudio() {
sessionWarning = nil
+ } else if !reasserted {
+ sessionWarning = AppL10n.string("flow.error.audioUnavailable")
}
refreshHostReady()
return
@@ -329,22 +367,94 @@ final class FlowSessionManager: ObservableObject {
/// Publish whether the keyboard can start a new utterance without jumping to the host app.
private func refreshHostReady() {
guard isActive else {
- FlowSessionBridge.setHostReady(false)
+ FlowSessionBridge.writeReadySnapshot(
+ FlowReadySnapshot(
+ sessionId: activeSessionId,
+ ready: false,
+ reason: .noSession,
+ engineMode: store.engineMode,
+ localeId: store.localeId,
+ sessionExpiresAt: FlowSessionBridge.sessionExpiresAt()
+ )
+ )
return
}
let pollingAlive = pollingTask != nil && pollingTask?.isCancelled != true
- // Steady-state ready uses structural engine liveness. The stricter
- // "recent audio frame" proof is reserved for cold-start handoff only
- // (`waitForAudioProof`) so brief UI-driven session hiccups do not
- // drop the keyboard back to orange while the host app is foreground.
+ let hasRecentAudio = capture.engineHasRecentAudio(maxAge: 2)
let canAcceptUtterance = capture.engineIsLive
&& pollingAlive
+ && hasRecentAudio
&& !isUtteranceRecording
&& !isUtteranceProcessing
&& sessionWarning == nil
- FlowSessionBridge.setHostReady(canAcceptUtterance)
+ let reason: FlowReadySnapshot.Reason
+ if canAcceptUtterance {
+ reason = .ready
+ } else if sessionWarning != nil {
+ reason = .error
+ } else if isUtteranceRecording {
+ reason = .recording
+ } else if isUtteranceProcessing {
+ reason = .processing
+ } else if !capture.engineIsLive {
+ reason = .audioEngineNotLive
+ } else if !hasRecentAudio {
+ reason = .waitingForAudioProof
+ } else {
+ reason = .starting
+ }
+
+ let now = Date().timeIntervalSince1970
+ FlowSessionBridge.writeReadySnapshot(
+ FlowReadySnapshot(
+ sessionId: activeSessionId,
+ ready: canAcceptUtterance,
+ reason: reason,
+ heartbeatAt: now,
+ readyAt: canAcceptUtterance ? now : nil,
+ audioProofAt: hasRecentAudio ? now : nil,
+ engineMode: store.engineMode,
+ localeId: store.localeId,
+ busyUtteranceId: isUtteranceRecording || isUtteranceProcessing ? currentUtteranceId : nil,
+ sessionExpiresAt: FlowSessionBridge.sessionExpiresAt()
+ )
+ )
+ let signature = [
+ canAcceptUtterance ? "ready=1" : "ready=0",
+ "reason=\(reason.rawValue)",
+ capture.engineIsLive ? "engine=live" : "engine=dead",
+ hasRecentAudio ? "audio=fresh" : "audio=stale",
+ isUtteranceRecording ? "recording=1" : "recording=0",
+ isUtteranceProcessing ? "processing=1" : "processing=0",
+ sessionWarning == nil ? "warning=0" : "warning=1"
+ ].joined(separator: "|")
+ if signature != lastReadyTraceSignature {
+ lastReadyTraceSignature = signature
+ traceState("hostReady.update", extra: signature)
+ }
+ reconcileColdStartOverlayIfRecovered()
+ }
+
+ /// When the host contract turns green while the cold-start overlay still
+ /// shows a stale preparing/failed snapshot, heal automatically.
+ private func reconcileColdStartOverlayIfRecovered() {
+ guard isColdStartHandoff, isActive else { return }
+ guard FlowSessionBridge.isHostReady() else { return }
+ guard let context = coldStartContext else { return }
+
+ switch context.state {
+ case .preparing:
+ presentColdStartReadyOverlay()
+ case .failed:
+ sessionWarning = nil
+ coldStartRecoveryTask?.cancel()
+ coldStartRecoveryTask = nil
+ dismissColdStartOverlay()
+ case .ready:
+ break
+ }
}
/// Home preview field gained focus while this app is the Flow host.
@@ -378,12 +488,14 @@ final class FlowSessionManager: ObservableObject {
// MARK: - Session start
private func startSessionAsync(duration: TimeInterval?) async {
+ traceState("startSessionAsync.begin")
isStarting = true
sessionWarning = nil
defer { isStarting = false }
guard AppPermissions.flowRequirementsMet else {
sessionWarning = permissionWarningMessage()
+ traceState("startSessionAsync.blocked", extra: "reason=permissions")
FlowSessionBridge.setHostReady(false)
if isColdStartHandoff {
showColdStartPermissionFailure()
@@ -397,6 +509,7 @@ final class FlowSessionManager: ObservableObject {
} catch {
let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
sessionWarning = message
+ traceState("startSessionAsync.failed", extra: "reason=captureStart error=\(message)")
FlowSessionBridge.setHostReady(false)
if isColdStartHandoff {
showColdStartAudioFailure(message: message)
@@ -408,23 +521,36 @@ final class FlowSessionManager: ObservableObject {
guard await waitForAudioProof() else {
let message = AppL10n.string("flow.coldStart.error.audioTimeout")
sessionWarning = message
- capture.stop()
+ traceState("startSessionAsync.failed", extra: "reason=audioProofTimeout")
FlowSessionBridge.setHostReady(false)
if isColdStartHandoff {
showColdStartAudioFailure(message: message)
+ scheduleColdStartRecovery(duration: duration)
+ } else {
+ capture.stop()
}
debug("continuous capture did not produce audio frames before timeout")
return
}
+ activateFlowSessionAfterAudioProof(duration: duration)
+ traceState("startSessionAsync.ready")
+ debug("Flow session started (\(Int(duration ?? FlowSessionPolicy.sessionDuration()))s inactivity window), continuous capture running")
+ }
+
+ private func activateFlowSessionAfterAudioProof(duration: TimeInterval?) {
let resolvedDuration = duration ?? FlowSessionPolicy.sessionDuration()
- FlowSessionBridge.markSessionActive(duration: resolvedDuration)
+ let sessionId = activeSessionId ?? UUID()
+ activeSessionId = sessionId
+ lastHandledCommandSeq = 0
+ FlowSessionBridge.markSessionActive(duration: resolvedDuration, sessionId: sessionId)
FlowSessionDarwin.postSessionChanged()
isActive = true
ScreenWakeLock.acquire()
sessionExpiresAt = Date().addingTimeInterval(resolvedDuration)
startHeartbeat()
+ startCommandObserver()
startPolling()
startLevelPublishing()
scheduleExpiry(after: resolvedDuration)
@@ -434,7 +560,7 @@ final class FlowSessionManager: ObservableObject {
FlowLiveActivityController.startSession()
refreshHostReady()
- debug("Flow session started (\(Int(resolvedDuration))s inactivity window), continuous capture running")
+ traceState("activateFlowSessionAfterAudioProof.done")
}
private func prepareExistingSessionForColdStartReturn() async {
@@ -445,6 +571,7 @@ final class FlowSessionManager: ObservableObject {
sessionWarning = message
FlowSessionBridge.setHostReady(false)
showColdStartAudioFailure(message: message)
+ scheduleColdStartRecovery(duration: nil)
debug("existing session failed cold-start audio proof")
return
}
@@ -466,25 +593,54 @@ final class FlowSessionManager: ObservableObject {
let message = AppL10n.string("flow.coldStart.error.audioTimeout")
sessionWarning = message
showColdStartAudioFailure(message: message)
+ scheduleColdStartRecovery(duration: nil)
debug("cold-start blocked: host ready contract not published")
return
}
- let hostEntry = HostReturnService.pendingHostEntry()
- let skipSwitch = FlowSessionPolicy.skipAppSwitch()
- coldStartContext = FlowColdStartContext(hostEntry: hostEntry, state: .ready)
+ presentColdStartReadyOverlay()
+ }
- if skipSwitch, hostEntry != nil {
- Task { @MainActor [weak self] in
- try? await Task.sleep(nanoseconds: 450_000_000)
- guard let self, self.coldStartContext?.state == .ready else { return }
- if HostReturnService.openPendingHostIfPossible() {
- self.dismissColdStartOverlay()
- }
+ private func presentColdStartReadyOverlay() {
+ let hostEntry = HostReturnService.pendingHostEntry()
+ coldStartContext = FlowColdStartContext(hostEntry: hostEntry, state: .ready)
+ scheduleAutoReturnToHostIfNeeded(hostEntry: hostEntry)
+ }
+
+ private func scheduleAutoReturnToHostIfNeeded(hostEntry: HostAppEntry?) {
+ let skipSwitch = FlowSessionPolicy.skipAppSwitch()
+ guard skipSwitch, hostEntry != nil else { return }
+ Task { @MainActor [weak self] in
+ try? await Task.sleep(nanoseconds: 450_000_000)
+ guard let self, self.coldStartContext?.state == .ready else { return }
+ if HostReturnService.openPendingHostIfPossible() {
+ self.dismissColdStartOverlay()
}
}
}
+ /// Keeps proving mic readiness after the first timeout instead of tearing
+ /// capture down — many handoffs become ready a few seconds later.
+ private func scheduleColdStartRecovery(duration: TimeInterval?) {
+ coldStartRecoveryTask?.cancel()
+ coldStartRecoveryTask = Task { @MainActor [weak self] in
+ guard let self else { return }
+ let recovered = await self.capture.awaitAudioFlowing(
+ timeout: Self.coldStartRecoveryProofTimeout
+ )
+ guard !Task.isCancelled else { return }
+ guard self.isColdStartHandoff else { return }
+ guard recovered else { return }
+
+ self.sessionWarning = nil
+ self.traceState("coldStartRecovery.recovered")
+ if !self.isActive {
+ self.activateFlowSessionAfterAudioProof(duration: duration)
+ }
+ self.refreshHostReady()
+ }
+ }
+
private func showColdStartPreparing() {
coldStartContext = FlowColdStartContext(
hostEntry: HostReturnService.pendingHostEntry(),
@@ -546,6 +702,14 @@ final class FlowSessionManager: ObservableObject {
// MARK: - Polling
+ private func startCommandObserver() {
+ commandObserver = FlowSessionDarwinObserver(
+ notificationName: FlowSessionDarwin.commandNotificationName
+ ) { [weak self] in
+ self?.handleKeyboardSignal()
+ }
+ }
+
private func startPolling() {
pollingTask?.cancel()
lastObservedRecordingState = FlowSessionBridge.recordingState()
@@ -556,46 +720,122 @@ final class FlowSessionManager: ObservableObject {
pollingTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
self?.handleKeyboardSignal()
- try? await Task.sleep(nanoseconds: 50_000_000)
+ try? await Task.sleep(nanoseconds: 500_000_000)
}
}
}
private func handleKeyboardSignal() {
- let signal = FlowSessionBridge.recordingState()
- if signal != lastObservedRecordingState {
- // The single most important cross-process signal: proves whether the
- // host actually SEES the keyboard's recording state writes.
- FlowDiagnostics.log(
- "poll observed recordingState \(lastObservedRecordingState.rawValue) → \(signal.rawValue) " +
- "[rec=\(isUtteranceRecording) proc=\(isUtteranceProcessing) fg=\(isAppForeground)]"
- )
- lastObservedRecordingState = signal
+ guard let command = FlowSessionBridge.latestCommand() else {
+ lastCommandFingerprint = ""
+ return
}
- switch signal {
- case .recording:
+ let fingerprint = "\(command.sessionId.uuidString)|\(command.utteranceId.uuidString)|\(command.action.rawValue)|\(command.commandSeq)"
+ guard fingerprint != lastCommandFingerprint else { return }
+ lastCommandFingerprint = fingerprint
+ handleFlowCommand(command)
+ }
+
+ private func handleFlowCommand(_ command: FlowCommand) {
+ guard let activeSessionId, command.sessionId == activeSessionId else {
+ traceIgnoredCommand(
+ reason: "staleSession",
+ command: command,
+ detail: "commandSession=\(command.sessionId)"
+ )
+ return
+ }
+ guard command.commandSeq > lastHandledCommandSeq else {
+ traceIgnoredCommand(
+ reason: "seqNotIncreasing",
+ command: command,
+ detail: "last=\(lastHandledCommandSeq)"
+ )
+ return
+ }
+ lastHandledCommandSeq = command.commandSeq
+ lastIgnoredCommandSignature = ""
+
+ FlowDiagnostics.log(
+ "command \(command.action.rawValue) seq=\(command.commandSeq) utterance=\(command.utteranceId)"
+ )
+
+ switch command.action {
+ case .startRecording:
guard !isUtteranceRecording, !isUtteranceProcessing else { return }
- beginUtterance()
- case .stopped:
+ beginUtterance(utteranceId: command.utteranceId, commandSeq: command.commandSeq)
+ case .stopRecording:
+ guard currentUtteranceId == command.utteranceId else { return }
if isUtteranceRecording {
endUtterance()
} else if !isUtteranceProcessing {
- FlowSessionBridge.setRecordingState(.idle)
- FlowSessionBridge.storeTranscriptionError(
+ storeCurrentError(
AppL10n.string("flow.error.recognitionInterrupted"),
kind: .recognitionInterrupted
)
- debug("stopped without active utterance — notified keyboard")
+ debug("stop command without active utterance — notified keyboard")
}
- case .aborted:
+ case .abort:
+ guard currentUtteranceId == command.utteranceId else { return }
abortUtterance()
- case .idle, .processing:
- break
}
}
- private func beginUtterance() {
- guard capture.engineIsLive else {
+ private func storeCurrentPartial(_ text: String) {
+ guard let activeSessionId, let currentUtteranceId else { return }
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return }
+ FlowSessionBridge.writeResult(
+ FlowResult(
+ sessionId: activeSessionId,
+ utteranceId: currentUtteranceId,
+ commandSeq: currentCommandSeq,
+ status: .partial,
+ text: trimmed
+ )
+ )
+ }
+
+ private func storeCurrentFinal(_ text: String, warning: String? = nil) {
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else {
+ storeCurrentError(AppL10n.string("flow.error.noSpeech"), kind: .noSpeech)
+ return
+ }
+ guard let activeSessionId, let currentUtteranceId else { return }
+ FlowSessionBridge.writeResult(
+ FlowResult(
+ sessionId: activeSessionId,
+ utteranceId: currentUtteranceId,
+ commandSeq: currentCommandSeq,
+ status: .final,
+ text: trimmed,
+ warning: warning
+ )
+ )
+ }
+
+ private func storeCurrentError(
+ _ message: String,
+ kind: FlowSessionKeys.TranscriptionErrorKind = .generic,
+ status: FlowResult.Status = .error
+ ) {
+ guard let activeSessionId, let currentUtteranceId else { return }
+ FlowSessionBridge.writeResult(
+ FlowResult(
+ sessionId: activeSessionId,
+ utteranceId: currentUtteranceId,
+ commandSeq: currentCommandSeq,
+ status: status,
+ text: message,
+ errorKind: kind
+ )
+ )
+ }
+
+ private func beginUtterance(utteranceId: UUID? = nil, commandSeq: Int64 = 0) {
+ guard capture.engineHasRecentAudio(maxAge: 2) else {
+ traceState("beginUtterance.blocked", extra: "reason=audioNotRecent")
failUtterance(
message: AppL10n.string("flow.error.audioUnavailable"),
kind: .audioUnavailable
@@ -603,13 +843,25 @@ final class FlowSessionManager: ObservableObject {
return
}
guard !isUtteranceProcessing else {
+ traceState("beginUtterance.ignored", extra: "reason=processing")
debug("beginUtterance ignored — previous utterance still processing")
return
}
+ bindSessionASRIfNeeded()
+ let expectedEngine = store.engineMode
+ if sessionASREngineMode != expectedEngine {
+ traceState(
+ "beginUtterance.rebindMismatch",
+ extra: "expectedEngine=\(expectedEngine) boundEngine=\(sessionASREngineMode ?? "nil")"
+ )
+ bindSessionASRIfNeeded(force: true)
+ }
// Usually already warm from session start; refresh without blocking the mic gate.
scheduleASRWarmup()
+ currentUtteranceId = utteranceId ?? UUID()
+ currentCommandSeq = commandSeq
currentPartial = ""
lastFinal = ""
chunkWarnings = []
@@ -617,6 +869,7 @@ final class FlowSessionManager: ObservableObject {
let localeId = store.localeId
FlowSessionBridge.setTranscriptionLanguage(localeId)
FlowSessionBridge.clearPendingTranscription()
+ FlowSessionBridge.clearResult()
let locale = SpeechLocaleResolver.resolve(localeId)
let stream = capture.beginUtterance()
@@ -625,6 +878,7 @@ final class FlowSessionManager: ObservableObject {
isUtteranceRecording = true
utteranceRecordingStartedAt = Date()
+ startUtteranceSafetyTimer()
refreshHostReady()
FlowLiveActivityController.update(phase: .recording)
FlowDiagnostics.log(
@@ -637,8 +891,9 @@ final class FlowSessionManager: ObservableObject {
asrTask = Task.detached(priority: .userInitiated) { [weak manager = self] in
let outcome = await pipeline.transcribe(stream: stream) { partial in
Task { @MainActor in
- manager?.currentPartial = partial
- FlowSessionBridge.storeTranscriptionPartial(partial)
+ guard let manager else { return }
+ manager.currentPartial = partial
+ manager.storeCurrentPartial(partial)
}
}
// Re-bind `manager` inside the `@MainActor` block so the
@@ -672,14 +927,33 @@ final class FlowSessionManager: ObservableObject {
debug("utterance recording started")
}
+ private func startUtteranceSafetyTimer() {
+ utteranceSafetyTask?.cancel()
+ let utteranceId = currentUtteranceId
+ utteranceSafetyTask = Task { @MainActor [weak self] in
+ let timeout = FlowSessionKeys.maxUtteranceDuration + 10
+ try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
+ guard let self, !Task.isCancelled else { return }
+ guard self.isUtteranceRecording, self.currentUtteranceId == utteranceId else { return }
+ self.storeCurrentError(
+ AppL10n.string("flow.error.recognitionInterrupted"),
+ kind: .recognitionInterrupted,
+ status: .timeout
+ )
+ self.abortUtterance()
+ self.debug("utterance safety timer aborted stale recording")
+ }
+ }
+
private func endUtterance() {
guard isUtteranceRecording else { return }
// Close the mic gate first, then mark processing before dropping the
// recording flag so the poll loop cannot start a second utterance.
- FlowSessionBridge.setRecordingState(.processing)
isUtteranceRecording = false
isUtteranceProcessing = true
+ utteranceSafetyTask?.cancel()
+ utteranceSafetyTask = nil
refreshHostReady()
FlowLiveActivityController.update(phase: .processing)
@@ -699,6 +973,8 @@ final class FlowSessionManager: ObservableObject {
isUtteranceRecording = false
isUtteranceProcessing = false
utteranceRecordingStartedAt = nil
+ utteranceSafetyTask?.cancel()
+ utteranceSafetyTask = nil
finalizeTask?.cancel()
finalizeTask = nil
asrTask?.cancel()
@@ -709,8 +985,8 @@ final class FlowSessionManager: ObservableObject {
currentPartial = ""
lastFinal = ""
chunkWarnings = []
- FlowSessionBridge.storeTranscriptionPartial("")
- FlowSessionBridge.setRecordingState(.idle)
+ currentUtteranceId = nil
+ currentCommandSeq = 0
FlowLiveActivityController.update(phase: .idle)
refreshHostReady()
debug("utterance aborted")
@@ -723,6 +999,8 @@ final class FlowSessionManager: ObservableObject {
isUtteranceRecording = false
isUtteranceProcessing = false
utteranceRecordingStartedAt = nil
+ utteranceSafetyTask?.cancel()
+ utteranceSafetyTask = nil
finalizeTask?.cancel()
finalizeTask = nil
asrTask?.cancel()
@@ -733,9 +1011,9 @@ final class FlowSessionManager: ObservableObject {
currentPartial = ""
lastFinal = ""
chunkWarnings = []
- FlowSessionBridge.storeTranscriptionPartial("")
- FlowSessionBridge.storeTranscriptionError(message, kind: kind)
- FlowSessionBridge.setRecordingState(.idle)
+ storeCurrentError(message, kind: kind)
+ currentUtteranceId = nil
+ currentCommandSeq = 0
FlowLiveActivityController.update(phase: .idle)
refreshHostReady()
debug("utterance failed: \(message)")
@@ -747,28 +1025,36 @@ final class FlowSessionManager: ObservableObject {
) {
isUtteranceProcessing = false
utteranceRecordingStartedAt = nil
+ utteranceSafetyTask?.cancel()
+ utteranceSafetyTask = nil
finalizeTask?.cancel()
finalizeTask = nil
chunkedPipeline = nil
currentPartial = ""
lastFinal = ""
chunkWarnings = []
- FlowSessionBridge.storeTranscriptionPartial("")
- FlowSessionBridge.storeTranscriptionError(message, kind: kind)
- FlowSessionBridge.setRecordingState(.idle)
+ storeCurrentError(message, kind: kind)
+ currentUtteranceId = nil
+ currentCommandSeq = 0
FlowLiveActivityController.update(phase: .idle)
refreshHostReady()
debug("utterance processing failed: \(message)")
}
private func finalizeUtterance() async {
+ let finalizeSessionId = activeSessionId
+ let finalizeUtteranceId = currentUtteranceId
let pipelineStarted = Date()
defer {
- isUtteranceProcessing = false
- FlowSessionBridge.setRecordingState(.idle)
- FlowLiveActivityController.update(phase: .idle)
- touchSessionActivity()
- refreshHostReady()
+ if activeSessionId == finalizeSessionId,
+ currentUtteranceId == finalizeUtteranceId {
+ isUtteranceProcessing = false
+ FlowLiveActivityController.update(phase: .idle)
+ touchSessionActivity()
+ currentUtteranceId = nil
+ currentCommandSeq = 0
+ refreshHostReady()
+ }
}
let asrWait = asrWaitTimeout()
@@ -783,9 +1069,10 @@ final class FlowSessionManager: ObservableObject {
try? await Task.sleep(nanoseconds: 100_000_000)
}
- if lastFinal.isEmpty, let asrTask {
- FlowDiagnostics.log("ASR wait elapsed — awaiting asrTask completion")
- _ = await asrTask.value
+ if lastFinal.isEmpty {
+ FlowDiagnostics.log("ASR wait elapsed — cancelling ASR task and using best available transcript")
+ asrTask?.cancel()
+ Task { await chunkedPipeline?.cancel() }
}
let asrElapsed = Date().timeIntervalSince(pipelineStarted)
@@ -803,10 +1090,7 @@ final class FlowSessionManager: ObservableObject {
(asrTask?.isCancelled == true) ? .recognitionInterrupted : .noSpeech
FlowDiagnostics.log("finalize failed: empty transcript after \(String(format: "%.1f", asrElapsed))s")
utteranceRecordingStartedAt = nil
- FlowSessionBridge.storeTranscriptionError(
- AppL10n.string(key),
- kind: kind
- )
+ storeCurrentError(AppL10n.string(key), kind: kind)
return
}
@@ -832,7 +1116,7 @@ final class FlowSessionManager: ObservableObject {
providerIdOverride: pipelineStore.polishProviderIdOverride
)
delivered = polished
- FlowSessionBridge.storeTranscriptionResult(polished, polishWarning: chunkNote)
+ storeCurrentFinal(polished, warning: chunkNote)
FlowDiagnostics.log(
"polish done in \(String(format: "%.1f", Date().timeIntervalSince(polishStarted)))s " +
"total=\(String(format: "%.1f", Date().timeIntervalSince(pipelineStarted)))s"
@@ -854,7 +1138,7 @@ final class FlowSessionManager: ObservableObject {
"\(error.localizedDescription)"
)
delivered = fallback.text
- FlowSessionBridge.storeTranscriptionResult(fallback.text, polishWarning: fallback.polishWarning)
+ storeCurrentFinal(fallback.text, warning: fallback.polishWarning)
}
SpeechHistoryStore.shared.recordUtterance(
@@ -867,7 +1151,6 @@ final class FlowSessionManager: ObservableObject {
currentPartial = ""
lastFinal = ""
chunkWarnings = []
- FlowSessionBridge.storeTranscriptionPartial("")
chunkedPipeline = nil
debug("utterance finalized length=\(text.count)")
}
@@ -964,6 +1247,12 @@ final class FlowSessionManager: ObservableObject {
heartbeatTask?.cancel()
FlowSessionBridge.writeHeartbeat()
heartbeatTask = Task { @MainActor [weak self] in
+ // Refresh the Live Activity `staleDate` every N heartbeat ticks
+ // (1 Hz) — well inside `FlowLiveActivityController.staleWindow` so a
+ // live session never looks stale, while a force-quit stops these
+ // refreshes and lets the system reclaim the orphaned island.
+ let liveActivityKeepAliveEveryTicks = 15
+ var tick = 0
while !Task.isCancelled {
guard let self else { break }
if self.isActive, !self.capture.engineIsLive {
@@ -971,6 +1260,10 @@ final class FlowSessionManager: ObservableObject {
}
FlowSessionBridge.writeHeartbeat()
self.refreshHostReady()
+ tick += 1
+ if tick % liveActivityKeepAliveEveryTicks == 0 {
+ FlowLiveActivityController.keepAlive()
+ }
try? await Task.sleep(nanoseconds: 1_000_000_000)
guard self.isActive else { break }
}
@@ -989,4 +1282,43 @@ final class FlowSessionManager: ObservableObject {
private func debug(_ message: String) {
FlowDiagnostics.log(message)
}
+
+ private func traceIgnoredCommand(reason: String, command: FlowCommand, detail: String) {
+ let signature = "\(reason)|\(command.action.rawValue)|\(command.commandSeq)|\(command.sessionId.uuidString)|\(command.utteranceId.uuidString)|\(detail)"
+ guard signature != lastIgnoredCommandSignature else { return }
+ lastIgnoredCommandSignature = signature
+ traceState(
+ "command.ignored",
+ extra: "reason=\(reason) action=\(command.action.rawValue) seq=\(command.commandSeq) \(detail)"
+ )
+ }
+
+ 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)",
+ "active=\(isActive)",
+ "starting=\(isStarting)",
+ "coldStart=\(isColdStartHandoff)",
+ "sessionId=\(sessionId)",
+ "utteranceId=\(utteranceId)",
+ "cmdSeq=\(currentCommandSeq)",
+ "lastCmd=\(lastHandledCommandSeq)",
+ "recording=\(isUtteranceRecording)",
+ "processing=\(isUtteranceProcessing)",
+ "storeEngine=\(store.engineMode)",
+ "boundEngine=\(sessionASREngineMode ?? "nil")",
+ "engineLive=\(capture.engineIsLive)",
+ "hostReady=\(FlowSessionBridge.isHostReady())",
+ "sessionActive=\(FlowSessionBridge.isSessionActive())",
+ "heartbeatStaleness=\(staleness)"
+ ].joined(separator: " ")
+ if let extra, !extra.isEmpty {
+ debug("[trace] \(summary) \(extra)")
+ } else {
+ debug("[trace] \(summary)")
+ }
+ }
}
diff --git a/OSGKeyboard/Views/FlowColdStartOverlay.swift b/OSGKeyboard/Views/FlowColdStartOverlay.swift
index 30f997c..7e09a04 100644
--- a/OSGKeyboard/Views/FlowColdStartOverlay.swift
+++ b/OSGKeyboard/Views/FlowColdStartOverlay.swift
@@ -38,6 +38,17 @@ struct FlowColdStartOverlay: View {
/// Fraction of the screen height the bottom gradient occupies.
private let gradientHeightFraction: CGFloat = 0.50
+ /// 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 {
GeometryReader { geo in
ZStack(alignment: .bottom) {
@@ -56,6 +67,16 @@ struct FlowColdStartOverlay: View {
.frame(maxWidth: .infinity, alignment: .bottom)
.allowsHitTesting(false)
+ if allowsBlankTapDismiss {
+ // Captures taps on empty overlay space (dismiss) and blocks
+ // pass-through to the host shell underneath. Action buttons in
+ // `content` sit above this layer and remain tappable.
+ Color.clear
+ .contentShape(Rectangle())
+ .onTapGesture(perform: onDismiss)
+ .ignoresSafeArea()
+ }
+
VStack(spacing: Spacing.lg) {
content
.padding(.horizontal, Spacing.xl)
@@ -63,14 +84,6 @@ struct FlowColdStartOverlay: View {
homeIndicator
.padding(.bottom, max(geo.safeAreaInsets.bottom, Spacing.sm))
}
- .allowsHitTesting(false)
-
- if context.state == .ready {
- Color.clear
- .contentShape(Rectangle())
- .onTapGesture(perform: onDismiss)
- .ignoresSafeArea()
- }
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
.ignoresSafeArea()
diff --git a/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift b/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift
index 3470fb6..1286a5e 100644
--- a/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift
+++ b/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift
@@ -42,6 +42,10 @@ final class KeyboardFlowCoordinator {
private static let hostReadyGrace: TimeInterval = 4
private var flowSessionMonitorTask: Task?
private var isAwaitingFlowResult = false
+ private var activeSessionId: UUID?
+ private var currentUtteranceId: UUID?
+ private var currentCommandSeq: Int64 = 0
+ private var lastAvailabilityTraceSignature = ""
init(
state: KeyboardState,
@@ -133,7 +137,9 @@ final class KeyboardFlowCoordinator {
private func recomputeMicVoiceAvailability() {
FlowSessionBridge.reloadFromDisk()
- let hostReady = FlowSessionBridge.isHostReady()
+ 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
@@ -145,7 +151,7 @@ final class KeyboardFlowCoordinator {
let hostWarming = !hostReady
&& FlowSessionBridge.isSessionActive()
&& (FlowSessionBridge.isHostReachable() || isPendingFlowStart || withinReadyGrace)
- state.flowSessionActive = hostReady
+ state.flowSessionActive = FlowSessionBridge.isSessionActive()
state.micVoiceAvailability = MicVoiceAvailabilityResolver.resolve(
phase: state.phase,
micDisabled: state.micDisabled,
@@ -154,6 +160,20 @@ final class KeyboardFlowCoordinator {
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
@@ -228,7 +248,8 @@ final class KeyboardFlowCoordinator {
scheduleAutoClearError()
recomputeMicVoiceAvailability()
case .unavailable(.preparingSession):
- return
+ detectAndStoreAppContext()
+ beginFlowStart()
case .unavailable(.hostNotReady):
detectAndStoreAppContext()
beginFlowStart()
@@ -247,8 +268,8 @@ 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()
@@ -256,7 +277,10 @@ final class KeyboardFlowCoordinator {
}
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
@@ -264,11 +288,11 @@ final class KeyboardFlowCoordinator {
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
@@ -278,6 +302,7 @@ final class KeyboardFlowCoordinator {
isPendingFlowStart = false
flowStartDeadline = 0
stopFlowWatchdog()
+ traceState("openHostApp.failed", extra: "path=startflow cancelPending=1")
showManualOpenHint(path: "startflow")
recomputeMicVoiceAvailability()
return
@@ -290,9 +315,10 @@ 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()
@@ -304,17 +330,56 @@ final class KeyboardFlowCoordinator {
// 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
@@ -330,6 +395,20 @@ final class KeyboardFlowCoordinator {
}
}
+ private func matchingResult() -> FlowResult? {
+ guard let result = FlowSessionBridge.latestResult() else { return nil }
+ guard let activeSessionId, let currentUtteranceId else { return nil }
+ guard result.sessionId == activeSessionId,
+ result.utteranceId == currentUtteranceId else {
+ return nil
+ }
+ 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() {
@@ -339,7 +418,8 @@ final class KeyboardFlowCoordinator {
isFlowRecording = false
stopUtteranceCountdown()
ExtensionScreenWakeLock.release()
- FlowSessionBridge.setRecordingState(.aborted)
+ writeCommand(.abort)
+ currentUtteranceId = nil
stopFlowWatchdog()
state.level = 0
state.phase = .idle
@@ -355,12 +435,14 @@ final class KeyboardFlowCoordinator {
}
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")
@@ -396,6 +478,7 @@ final class KeyboardFlowCoordinator {
private func startFlowRecording() {
recomputeMicVoiceAvailability()
guard state.micVoiceAvailability.isReady else {
+ traceState("startFlowRecording.blocked", extra: "availability=\(String(describing: state.micVoiceAvailability))")
beginFlowStart()
return
}
@@ -403,8 +486,14 @@ 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
@@ -414,9 +503,7 @@ final class KeyboardFlowCoordinator {
}
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() {
@@ -450,6 +537,7 @@ final class KeyboardFlowCoordinator {
state.phase = .idle
state.lastTranscript = ""
recomputeMicVoiceAvailability()
+ traceState("pendingStart.cancelledByUser")
}
private func startFlowStartWatchdog() {
@@ -465,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
}
@@ -480,7 +569,7 @@ final class KeyboardFlowCoordinator {
state.lastTranscript = ""
refreshSessionState()
startFlowRecording()
- debug("completeFlowStartHandoff → auto startFlowRecording")
+ traceState("completeFlowStartHandoff.done")
}
private func startFlowLevelWatchdog() {
@@ -492,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)
}
}
@@ -501,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:
@@ -517,16 +615,33 @@ 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),
@@ -539,6 +654,11 @@ final class KeyboardFlowCoordinator {
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()
@@ -553,6 +673,7 @@ 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)
@@ -573,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)")
+ }
+ }
}
diff --git a/OSGKeyboardLiveActivity/FlowLiveActivityWidget.swift b/OSGKeyboardLiveActivity/FlowLiveActivityWidget.swift
index c3af7cb..f8a3f74 100644
--- a/OSGKeyboardLiveActivity/FlowLiveActivityWidget.swift
+++ b/OSGKeyboardLiveActivity/FlowLiveActivityWidget.swift
@@ -52,7 +52,7 @@ private struct FlowLiveActivityLockScreenView: View {
var body: some View {
HStack(spacing: 12) {
- FlowLiveActivityBrandMark(height: 18)
+ FlowLiveActivityBrandMark(height: 13)
VStack(alignment: .leading, spacing: 4) {
Text("OSGKeyboard")
.font(.headline)
@@ -99,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.
diff --git a/OSGKeyboardMac/DashboardView.swift b/OSGKeyboardMac/DashboardView.swift
index 0fe37c6..ab2f4b1 100644
--- a/OSGKeyboardMac/DashboardView.swift
+++ b/OSGKeyboardMac/DashboardView.swift
@@ -183,37 +183,48 @@ struct BottomDictationBar: View {
.fixedSize()
}
+ // 麦克风按钮始终居中固定:录音时的波形放进按钮内部,
+ // “按停止”提示作为浮层显示在按钮上方,二者均不参与布局,
+ // 因此按下 Option 触发录音时按钮位置不会发生偏移。
private var recordControl: some View {
- HStack(spacing: Spacing.sm) {
- if viewModel.isRecording {
- MiniWaveform(level: viewModel.audioLevel)
+ 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)
+ }
}
- Button(action: viewModel.toggleRecording) {
- ZStack {
- Circle()
- .fill(viewModel.isRecording ? palette.recordRed : palette.accent)
- .frame(width: 52, height: 52)
- .macGlassSurface(in: Circle(), fillOpacity: 0.2)
- .shadow(
- color: (viewModel.isRecording ? palette.recordRed : palette.accent).opacity(0.5),
- radius: pulse ? 14 : 6
- )
- Image(systemName: viewModel.isRecording ? "stop.fill" : "mic.fill")
+ }
+
+ private var recordButton: some View {
+ Button(action: viewModel.toggleRecording) {
+ ZStack {
+ Circle()
+ .fill(viewModel.isRecording ? palette.recordRed : palette.accent)
+ .frame(width: 52, height: 52)
+ .macGlassSurface(in: Circle(), fillOpacity: 0.2)
+ .shadow(
+ color: (viewModel.isRecording ? palette.recordRed : palette.accent).opacity(0.5),
+ radius: pulse ? 14 : 6
+ )
+ 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)
}
}
- .buttonStyle(.plain)
- .disabled(viewModel.isProcessing)
- .onAppear {
- withAnimation(.easeInOut(duration: 1.1).repeatForever(autoreverses: true)) {
- pulse = true
- }
- }
- if viewModel.isRecording {
- Text(MacL10n.string("mac.record.pressStop", language: lang))
- .font(TypeStyle.caption)
- .foregroundStyle(palette.textTertiary)
+ }
+ .buttonStyle(.plain)
+ .disabled(viewModel.isProcessing)
+ .onAppear {
+ withAnimation(.easeInOut(duration: 1.1).repeatForever(autoreverses: true)) {
+ pulse = true
}
}
}
diff --git a/OSGKeyboardMac/MacDictationPipeline.swift b/OSGKeyboardMac/MacDictationPipeline.swift
index 0125768..6693584 100644
--- a/OSGKeyboardMac/MacDictationPipeline.swift
+++ b/OSGKeyboardMac/MacDictationPipeline.swift
@@ -31,9 +31,30 @@ enum MacDictationPipeline {
let locale = Locale(identifier: store.localeId.isEmpty ? "zh-CN" : store.localeId)
let raw: String
+ var localBias: LocalASRBiasPayload?
if store.engineMode == "local" {
- raw = try await MacLocalASRService.transcribe(samples: samples, locale: locale)
+ 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 }
@@ -51,13 +72,33 @@ enum MacDictationPipeline {
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(
- trimmed,
- mode: store.polishModeForPipeline
+ postASR,
+ mode: store.polishModeForPipeline,
+ context: polishContext
),
!polished.isEmpty {
return polished
}
- return trimmed
+ return postASR
}
}
diff --git a/OSGKeyboardMac/MacDictationViewModel.swift b/OSGKeyboardMac/MacDictationViewModel.swift
index c801711..09f3786 100644
--- a/OSGKeyboardMac/MacDictationViewModel.swift
+++ b/OSGKeyboardMac/MacDictationViewModel.swift
@@ -107,8 +107,9 @@ final class MacDictationViewModel: ObservableObject {
/// Pre-load MLX weights + Metal shaders so the first dictation is fast.
func warmUpQwen3IfNeeded() {
guard config.engineMode == "local",
- MacLocalASRPreferences.backend == .qwen3MLX,
- MacLocalASRPreferences.qwen3ModelIsInstalled() else { return }
+ let model = MacLocalASRService.selectedModelDefinition(),
+ model.backend == .mlx,
+ MacLocalASRService.isModelInstalled(model) else { return }
let path = MacLocalASRPreferences.qwen3ModelPath
Task.detached(priority: .utility) {
_ = try? await MacQwen3ASREngine.shared.prepareIfNeeded(modelPath: path)
@@ -156,6 +157,39 @@ final class MacDictationViewModel: ObservableObject {
return "\(seconds)s"
}
+ var localModelReady: Bool {
+ _ = localModelRevision
+ if let model = MacLocalASRService.selectedModelDefinition() {
+ return MacLocalASRService.isModelInstalled(model)
+ }
+ return MacLocalASRPreferences.qwen3ModelIsInstalled()
+ }
+
+ /// 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)
+ }
+ if model.installKind == .manual {
+ return MacL10n.string("mac.settings.mlxModelMissing", 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()
+ }
+
var qwen3ModelInstalled: Bool {
MacLocalASRPreferences.qwen3ModelIsInstalled()
}
diff --git a/OSGKeyboardMac/MacLocalASRModelSettingsView.swift b/OSGKeyboardMac/MacLocalASRModelSettingsView.swift
new file mode 100644
index 0000000..fc10bfd
--- /dev/null
+++ b/OSGKeyboardMac/MacLocalASRModelSettingsView.swift
@@ -0,0 +1,455 @@
+// MacLocalASRModelSettingsView.swift
+// OSGKeyboard · Mac
+//
+// Local ASR model catalog, download progress, MLX path, 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?
+
+ deinit {
+ progressPollTask?.cancel()
+ }
+
+ func reload() {
+ catalog = try? LocalASRModelCatalog.loadBundled()
+ if let catalog {
+ let manifest = LocalASRInstalledManifestIO.load(defaultModelId: catalog.defaultModelId)
+ selectedModelId = 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,
+ 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])
+ }
+
+ /// Opens (creating if needed) the model's shared subfolder so the user can
+ /// drop in manually-converted weights (used by the MLX model).
+ func revealModelFolder(_ model: LocalASRModelDefinition) {
+ guard let relative = model.installRelativePath else { return }
+ let url = LocalASRModelInstallState.installDirectory(for: relative)
+ try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
+ NSWorkspace.shared.open(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)
+ runtimeSection(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 {
+ 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))
+ } footer: {
+ Text(MacL10n.string("mac.localASR.modelsDesc", language: lang))
+ .font(TypeStyle.caption)
+ .foregroundStyle(palette.textSecondary)
+ }
+ }
+
+ private func runtimeSection(catalog: LocalASRCatalogDocument) -> some View {
+ Group {
+ if let runtime = modelVM.currentRuntime(in: catalog) {
+ Section {
+ LabeledContent(runtime.displayName) {
+ Text(
+ modelVM.isRuntimeInstalled(runtime)
+ ? MacL10n.string("mac.localASR.installed", language: lang)
+ : MacL10n.string("mac.localASR.notInstalled", language: lang)
+ )
+ }
+ Text(MacL10n.string("mac.localASR.runtimeDesc", language: lang))
+ .font(TypeStyle.caption)
+ .foregroundStyle(palette.textSecondary)
+ } header: {
+ Text(MacL10n.string("mac.localASR.runtime", 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)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(model.displayName)
+ .foregroundStyle(palette.textPrimary)
+ Text(modelSubtitle(model, installed: installed))
+ .font(TypeStyle.caption)
+ .foregroundStyle(palette.textSecondary)
+ }
+ }
+ }
+ .buttonStyle(.plain)
+ .disabled(modelVM.isInstalling)
+
+ Spacer()
+
+ modelRowActions(model: model, installed: installed, installing: installing)
+ }
+ }
+ .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 model.installKind == .manual {
+ Button(MacL10n.string("mac.localASR.openFolder", language: lang)) {
+ modelVM.revealModelFolder(model)
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ } else if installed {
+ Button(MacL10n.string("mac.localASR.delete", language: lang), role: .destructive) {
+ modelVM.deleteModel(model)
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ } 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(.linear(duration: 0.15), 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)
+ let hotword = model.supportsHotwords
+ ? MacL10n.string("mac.localASR.hotwordsYes", language: lang)
+ : MacL10n.string("mac.localASR.hotwordsNo", language: lang)
+ let state = installed
+ ? MacL10n.string("mac.localASR.installed", language: lang)
+ : MacL10n.string("mac.localASR.notInstalled", language: lang)
+ if let usage = modelVM.installedDiskUsage(model) {
+ return "\(size) · \(hotword) · \(state) · \(usage)"
+ }
+ return "\(size) · \(hotword) · \(state)"
+ }
+
+ 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)
+ }
+ }
+}
diff --git a/OSGKeyboardMac/MacLocalASRService.swift b/OSGKeyboardMac/MacLocalASRService.swift
index 0f7f3e9..6d185fc 100644
--- a/OSGKeyboardMac/MacLocalASRService.swift
+++ b/OSGKeyboardMac/MacLocalASRService.swift
@@ -1,13 +1,15 @@
// MacLocalASRService.swift
// OSGKeyboard · Mac
//
-// On-device ASR for macOS. Primary: Qwen3-ASR-1.7B (MLX via mlx-swift-asr).
-// Falls back to Apple Speech when Qwen3 weights are absent or backend is Apple Speech.
+// On-device ASR for macOS. Routes through the bundled local ASR catalog:
+// Qwen3 MLX (default), Sherpa Qwen3 hotwords POC, SenseVoice, Apple Speech fallback.
import Foundation
enum MacLocalASRBackend: String, Sendable, CaseIterable {
case qwen3MLX
+ case sherpaQwen3
+ case sherpaSenseVoice
case appleSpeech
}
@@ -39,28 +41,33 @@ enum MacLocalASRError: Error, LocalizedError {
enum MacLocalASRPreferences {
static let backendKey = "mac.localASR.backend"
- static let qwen3ModelPathKey = "mac.localASR.qwen3ModelPath"
+ static let selectedModelIdKey = LocalASRPreferenceKeys.selectedModelId
+ /// Shared managed subfolder for the manually-provided MLX weights.
+ static let qwen3ModelRelativePath = "models/qwen3-asr-1.7b-mlx"
- static var backend: MacLocalASRBackend {
+ static var selectedModelId: String {
get {
- guard let raw = UserDefaults.standard.string(forKey: backendKey),
- let value = MacLocalASRBackend(rawValue: raw) else {
- return .qwen3MLX
+ if let raw = UserDefaults.standard.string(forKey: selectedModelIdKey), !raw.isEmpty {
+ return raw
}
- return value
+ return legacyBackend == .appleSpeech ? "apple-speech-fallback" : "qwen3-mlx-1.7b"
}
- set { UserDefaults.standard.set(newValue.rawValue, forKey: backendKey) }
+ set { UserDefaults.standard.set(newValue, forKey: selectedModelIdKey) }
}
+ static var legacyBackend: MacLocalASRBackend {
+ guard let raw = UserDefaults.standard.string(forKey: backendKey),
+ let value = MacLocalASRBackend(rawValue: raw) else {
+ return .qwen3MLX
+ }
+ return value
+ }
+
+ /// Fixed location inside the shared managed model storage root. All three
+ /// catalog models live under the same directory, so MLX no longer needs a
+ /// per-model folder picker — the user drops converted weights here.
static var qwen3ModelPath: String {
- get { UserDefaults.standard.string(forKey: qwen3ModelPathKey) ?? defaultQwen3ModelPath }
- set { UserDefaults.standard.set(newValue, forKey: qwen3ModelPathKey) }
- }
-
- /// Default install location for MLX-converted Qwen3-ASR weights.
- static var defaultQwen3ModelPath: String {
- let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
- return appSupport.appendingPathComponent("OSGKeyboard/models/qwen3-asr-1.7b-mlx", isDirectory: true).path
+ LocalASRModelInstallState.installDirectory(for: qwen3ModelRelativePath).path
}
static func qwen3ModelIsInstalled(at path: String = qwen3ModelPath) -> Bool {
@@ -80,24 +87,93 @@ enum MacLocalASRPreferences {
}
enum MacLocalASRService {
- /// Transcribe using the user's preferred local backend with automatic
- /// fallback to Apple Speech when Qwen3 weights are not present.
- static func transcribe(samples: [Float], locale: Locale) async throws -> String {
- let preferQwen3 = MacLocalASRPreferences.backend == .qwen3MLX
- if preferQwen3, MacLocalASRPreferences.qwen3ModelIsInstalled() {
+
+ 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
+ : 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: MacLocalASRPreferences.qwen3ModelPath
+ )
+ }
+
+ /// Transcribe using the selected catalog model, with MLX → Apple Speech fallback.
+ static func transcribe(
+ samples: [Float],
+ locale: Locale,
+ bias: LocalASRBiasPayload? = nil
+ ) async throws -> String {
+ if let model = selectedModelDefinition(), isModelInstalled(model) {
do {
- return try await MacQwen3LocalASR.transcribe(
- samples: samples,
- sampleRate: 16_000,
- locale: locale,
- modelPath: MacLocalASRPreferences.qwen3ModelPath
- )
- } catch MacLocalASRError.qwen3ModelMissing {
- // Fall through to Apple Speech when weights are absent.
+ return try await transcribeWithModel(model, samples: samples, locale: locale, bias: bias)
} catch {
- throw error
+ if model.backend != .mlx {
+ throw error
+ }
}
}
+
+ if MacLocalASRPreferences.qwen3ModelIsInstalled() {
+ return try await MacQwen3LocalASR.transcribe(
+ samples: samples,
+ sampleRate: 16_000,
+ locale: locale,
+ modelPath: MacLocalASRPreferences.qwen3ModelPath,
+ 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:
+ return try await MacQwen3LocalASR.transcribe(
+ samples: samples,
+ sampleRate: 16_000,
+ locale: locale,
+ modelPath: MacLocalASRPreferences.qwen3ModelPath,
+ bias: bias
+ )
+ case .sherpaQwen3, .sherpaSenseVoice:
+ 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)
+ }
+ }
}
diff --git a/OSGKeyboardMac/MacQwen3ASREngine.swift b/OSGKeyboardMac/MacQwen3ASREngine.swift
index a2eee4b..e06fc3b 100644
--- a/OSGKeyboardMac/MacQwen3ASREngine.swift
+++ b/OSGKeyboardMac/MacQwen3ASREngine.swift
@@ -50,14 +50,15 @@ actor MacQwen3ASREngine {
func transcribe(
samples: [Float],
language: String?,
- modelPath: 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)
+ 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
diff --git a/OSGKeyboardMac/MacQwen3LocalASR.swift b/OSGKeyboardMac/MacQwen3LocalASR.swift
index 377609e..cd46822 100644
--- a/OSGKeyboardMac/MacQwen3LocalASR.swift
+++ b/OSGKeyboardMac/MacQwen3LocalASR.swift
@@ -12,7 +12,8 @@ enum MacQwen3LocalASR {
samples: [Float],
sampleRate: Int,
locale: Locale,
- modelPath: String
+ modelPath: String,
+ bias: LocalASRBiasPayload? = nil
) async throws -> String {
guard MacLocalASRPreferences.qwen3ModelIsInstalled(at: modelPath) else {
throw MacLocalASRError.qwen3ModelMissing
@@ -24,11 +25,14 @@ enum MacQwen3LocalASR {
}
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
+ modelPath: modelPath,
+ context: promptContext
)
} catch let error as MacLocalASRError {
throw error
diff --git a/OSGKeyboardMac/MacRootView.swift b/OSGKeyboardMac/MacRootView.swift
index dc904e4..d4552d9 100644
--- a/OSGKeyboardMac/MacRootView.swift
+++ b/OSGKeyboardMac/MacRootView.swift
@@ -56,7 +56,6 @@ struct MacRootView: View {
Spacer()
devicesFooter
}
- .background(palette.surfaceMuted)
}
private func sidebarRow(_ section: MacSection) -> some View {
diff --git a/OSGKeyboardMac/MacSettingsView.swift b/OSGKeyboardMac/MacSettingsView.swift
index 6648178..a4b607a 100644
--- a/OSGKeyboardMac/MacSettingsView.swift
+++ b/OSGKeyboardMac/MacSettingsView.swift
@@ -37,7 +37,7 @@ struct MacSettingsView: View {
providerSection
}
if viewModel.config.engineMode == "local" {
- qwen3Section
+ MacLocalASRModelSettingsView(viewModel: viewModel)
}
inputSection
syncSection
@@ -147,12 +147,6 @@ struct MacSettingsView: View {
systemImage: "cpu",
selected: viewModel.config.engineMode == "local"
) { viewModel.setEngineMode("local") }
-
- if viewModel.config.engineMode == "local", !viewModel.qwen3ModelInstalled {
- Label(MacL10n.string("mac.settings.qwen3Missing", language: lang), systemImage: "exclamationmark.triangle")
- .font(TypeStyle.caption)
- .foregroundStyle(palette.warning)
- }
}
}
@@ -196,25 +190,7 @@ struct MacSettingsView: View {
}
}
- // MARK: - Qwen3 model path
-
- private var qwen3Section: some View {
- Section {
- HStack(spacing: Spacing.sm) {
- TextField("", text: qwen3PathBinding, prompt: Text(verbatim: "~/Models/Qwen3-ASR"))
- .macFieldStyle()
- Button(MacL10n.string("mac.settings.qwen3Browse", language: lang)) {
- pickQwen3Folder()
- }
- }
- } header: {
- Text(MacL10n.string("mac.settings.qwen3Model", language: lang))
- } footer: {
- Text(MacL10n.string("mac.settings.qwen3ModelDesc", language: lang))
- .font(TypeStyle.caption)
- .foregroundStyle(palette.textSecondary)
- }
- }
+ // MARK: - Qwen3 model path (legacy — see MacLocalASRModelSettingsView)
// MARK: - Row helpers
@@ -366,17 +342,6 @@ struct MacSettingsView: View {
)
}
- private var qwen3PathBinding: Binding {
- Binding(
- get: { MacLocalASRPreferences.qwen3ModelPath },
- set: { newPath in
- MacLocalASRPreferences.qwen3ModelPath = newPath
- Task { await MacQwen3ASREngine.shared.unload() }
- viewModel.warmUpQwen3IfNeeded()
- }
- )
- }
-
// MARK: - AppKit actions (macOS only)
private func openAccessibilitySettings() {
@@ -413,19 +378,4 @@ struct MacSettingsView: View {
private var accessibilityStatusNeeded: String {
lang.resolvedLanguageCode().hasPrefix("zh") ? "未授权" : "Needed"
}
-
- private func pickQwen3Folder() {
- #if os(macOS)
- let panel = NSOpenPanel()
- panel.canChooseDirectories = true
- panel.canChooseFiles = false
- panel.allowsMultipleSelection = false
- panel.begin { response in
- guard response == .OK, let url = panel.url else { return }
- MacLocalASRPreferences.qwen3ModelPath = url.path
- Task { await MacQwen3ASREngine.shared.unload() }
- viewModel.warmUpQwen3IfNeeded()
- }
- #endif
- }
}
diff --git a/OSGKeyboardMac/MacSherpaLocalASR.swift b/OSGKeyboardMac/MacSherpaLocalASR.swift
new file mode 100644
index 0000000..94b632e
--- /dev/null
+++ b/OSGKeyboardMac/MacSherpaLocalASR.swift
@@ -0,0 +1,56 @@
+// 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
+ )
+ default:
+ throw MacLocalASRError.qwen3InferenceFailed("Unsupported Sherpa backend")
+ }
+ }
+}
diff --git a/OSGKeyboardMac/MacSherpaONNXRunner.swift b/OSGKeyboardMac/MacSherpaONNXRunner.swift
new file mode 100644
index 0000000..d4116b8
--- /dev/null
+++ b/OSGKeyboardMac/MacSherpaONNXRunner.swift
@@ -0,0 +1,153 @@
+// 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)
+ }
+
+ // 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 ""
+ }
+}
diff --git a/OSGKeyboardMac/OSGKeyboardMac.entitlements b/OSGKeyboardMac/OSGKeyboardMac.entitlements
index 422ac05..fa4ef6b 100644
--- a/OSGKeyboardMac/OSGKeyboardMac.entitlements
+++ b/OSGKeyboardMac/OSGKeyboardMac.entitlements
@@ -3,7 +3,7 @@
com.apple.security.app-sandbox
-
+
com.apple.security.device.audio-input
com.apple.security.network.client
diff --git a/OSGKeyboardMac/OSGKeyboardMacApp.swift b/OSGKeyboardMac/OSGKeyboardMacApp.swift
index 0895bf8..a87c048 100644
--- a/OSGKeyboardMac/OSGKeyboardMacApp.swift
+++ b/OSGKeyboardMac/OSGKeyboardMacApp.swift
@@ -86,6 +86,20 @@ final class MacAppDelegate: NSObject, NSApplicationDelegate {
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.
@@ -96,18 +110,47 @@ final class MacAppDelegate: NSObject, NSApplicationDelegate {
private func configureStatusItem() {
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = item.button {
- // Prefer the brand mark; fall back to an SF Symbol so the item is
- // never invisible even if the asset fails to resolve.
- let image = NSImage(named: "OSGBrandMark")
- ?? NSImage(systemSymbolName: "mic.circle.fill", accessibilityDescription: "OSGKeyboard")
- image?.isTemplate = true
- image?.size = NSSize(width: 18, height: 18)
- button.image = image
+ 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() {
diff --git a/OSGKeyboardShared/Models/LocalASRBiasPayload.swift b/OSGKeyboardShared/Models/LocalASRBiasPayload.swift
new file mode 100644
index 0000000..392fa78
--- /dev/null
+++ b/OSGKeyboardShared/Models/LocalASRBiasPayload.swift
@@ -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
+ }
+}
diff --git a/OSGKeyboardShared/Models/LocalASRCapabilities.swift b/OSGKeyboardShared/Models/LocalASRCapabilities.swift
new file mode 100644
index 0000000..c5e33d5
--- /dev/null
+++ b/OSGKeyboardShared/Models/LocalASRCapabilities.swift
@@ -0,0 +1,82 @@
+// 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
+ )
+}
diff --git a/OSGKeyboardShared/Models/LocalASRModelCatalog.swift b/OSGKeyboardShared/Models/LocalASRModelCatalog.swift
new file mode 100644
index 0000000..d4f2f12
--- /dev/null
+++ b/OSGKeyboardShared/Models/LocalASRModelCatalog.swift
@@ -0,0 +1,134 @@
+// 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 appleSpeech
+}
+
+public enum LocalASRInstallKind: String, Codable, Sendable, Equatable {
+ case manual
+ case archive
+ case runtime
+}
+
+public struct LocalASRDownloadSource: Codable, Sendable, Equatable {
+ public let type: String
+ public let priority: Int
+ public let url: String
+}
+
+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 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 .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
+}
+
+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 {}
diff --git a/OSGKeyboardShared/Models/PersonalDictionary+ASRBias.swift b/OSGKeyboardShared/Models/PersonalDictionary+ASRBias.swift
index 1a2341d..1215edb 100644
--- a/OSGKeyboardShared/Models/PersonalDictionary+ASRBias.swift
+++ b/OSGKeyboardShared/Models/PersonalDictionary+ASRBias.swift
@@ -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()
+ 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
+ }
+ }
}
diff --git a/OSGKeyboardShared/Models/PolishContext.swift b/OSGKeyboardShared/Models/PolishContext.swift
index b85a148..4977123 100644
--- a/OSGKeyboardShared/Models/PolishContext.swift
+++ b/OSGKeyboardShared/Models/PolishContext.swift
@@ -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
}
diff --git a/OSGKeyboardShared/Models/ProviderConfig.swift b/OSGKeyboardShared/Models/ProviderConfig.swift
index 2259dc3..6bf4839 100644
--- a/OSGKeyboardShared/Models/ProviderConfig.swift
+++ b/OSGKeyboardShared/Models/ProviderConfig.swift
@@ -74,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 {
diff --git a/OSGKeyboardShared/Resources/LocalASR/local-asr-catalog.json b/OSGKeyboardShared/Resources/LocalASR/local-asr-catalog.json
new file mode 100644
index 0000000..610986b
--- /dev/null
+++ b/OSGKeyboardShared/Resources/LocalASR/local-asr-catalog.json
@@ -0,0 +1,102 @@
+{
+ "schemaVersion": 1,
+ "defaultModelId": "qwen3-mlx-1.7b",
+ "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": "qwen3-mlx-1.7b",
+ "displayName": "Qwen3-ASR 1.7B (MLX)",
+ "backend": "mlx",
+ "sizeBytes": 1400000000,
+ "recommendedLocales": ["zh-CN", "en-US"],
+ "supportsHotwords": true,
+ "hotwordMode": "promptOnly",
+ "installKind": "manual",
+ "installRelativePath": "models/qwen3-asr-1.7b-mlx",
+ "requiredRelativeFiles": ["config.json", "model.safetensors", "vocab.json", "merges.txt"]
+ },
+ {
+ "id": "sherpa-qwen3-0.6b-int8",
+ "displayName": "Qwen3-ASR 0.6B (Sherpa · hotwords)",
+ "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-sensevoice-small-int8",
+ "displayName": "SenseVoice Small (Sherpa)",
+ "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"
+ }
+ ]
+ }
+ ]
+}
diff --git a/OSGKeyboardShared/Services/AppGroupStore.swift b/OSGKeyboardShared/Services/AppGroupStore.swift
index f1e7bdf..a021bd9 100644
--- a/OSGKeyboardShared/Services/AppGroupStore.swift
+++ b/OSGKeyboardShared/Services/AppGroupStore.swift
@@ -92,6 +92,7 @@ public struct AppGroupStore: @unchecked Sendable {
config.model = openAI.defaultModel
}
}
+ AppGroupConfigDarwin.postConfigChanged()
}
public func setUILanguage(_ language: AppUILanguage) {
diff --git a/OSGKeyboardShared/Services/BuiltinLexiconIndex.swift b/OSGKeyboardShared/Services/BuiltinLexiconIndex.swift
new file mode 100644
index 0000000..815e5a8
--- /dev/null
+++ b/OSGKeyboardShared/Services/BuiltinLexiconIndex.swift
@@ -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? = 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()
+ 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)
+ }
+ }
+}
diff --git a/OSGKeyboardShared/Services/FlowContinuousCapture.swift b/OSGKeyboardShared/Services/FlowContinuousCapture.swift
index 9e7e4d9..3e835bc 100644
--- a/OSGKeyboardShared/Services/FlowContinuousCapture.swift
+++ b/OSGKeyboardShared/Services/FlowContinuousCapture.swift
@@ -384,19 +384,26 @@ public final class FlowContinuousCapture {
/// 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
}
- notifyEngineLiveChanged()
}
public func awaitAudioFlowing(
diff --git a/OSGKeyboardShared/Services/FlowSessionBridge.swift b/OSGKeyboardShared/Services/FlowSessionBridge.swift
index e963cda..353e7e7 100644
--- a/OSGKeyboardShared/Services/FlowSessionBridge.swift
+++ b/OSGKeyboardShared/Services/FlowSessionBridge.swift
@@ -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,6 +188,15 @@ public enum FlowSessionBridge {
}
}
+ private static func encode(_ value: T) -> Data? {
+ try? JSONEncoder().encode(value)
+ }
+
+ private static func decode(_ 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) {
@@ -45,10 +206,87 @@ public enum FlowSessionBridge {
}
}
+ // 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)
@@ -59,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)
}
@@ -69,8 +325,11 @@ 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)
}
@@ -185,6 +444,15 @@ public enum FlowSessionBridge {
/// 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)
}
@@ -393,6 +661,10 @@ 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)
diff --git a/OSGKeyboardShared/Services/FlowSessionDarwin.swift b/OSGKeyboardShared/Services/FlowSessionDarwin.swift
index 4c5d942..a80e063 100644
--- a/OSGKeyboardShared/Services/FlowSessionDarwin.swift
+++ b/OSGKeyboardShared/Services/FlowSessionDarwin.swift
@@ -8,6 +8,8 @@ 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.
@@ -23,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(),
diff --git a/OSGKeyboardShared/Services/FlowSessionKeys.swift b/OSGKeyboardShared/Services/FlowSessionKeys.swift
index 6d4403a..f7513a3 100644
--- a/OSGKeyboardShared/Services/FlowSessionKeys.swift
+++ b/OSGKeyboardShared/Services/FlowSessionKeys.swift
@@ -7,6 +7,10 @@
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"
@@ -74,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
diff --git a/OSGKeyboardShared/Services/LocalASRBiasAdapter.swift b/OSGKeyboardShared/Services/LocalASRBiasAdapter.swift
new file mode 100644
index 0000000..351ab49
--- /dev/null
+++ b/OSGKeyboardShared/Services/LocalASRBiasAdapter.swift
@@ -0,0 +1,159 @@
+// LocalASRBiasAdapter.swift
+// OSGKeyboard · Shared
+//
+// Maps `PersonalDictionary` + builtin lexicon + runtime context into the
+// layered bias outputs consumed by local ASR, correction, and polish.
+
+import Foundation
+
+public enum LocalASRBiasAdapter {
+
+ /// Bundle IDs where computer-science vocabulary is especially likely.
+ private static let codeEditorBundleIDs: Set = [
+ "com.apple.dt.Xcode",
+ "com.microsoft.VSCode",
+ "com.google.android.studio",
+ "com.jetbrains.intellij",
+ "com.jetbrains.AppCode",
+ "com.sublimetext.4",
+ "com.apple.Terminal",
+ "com.googlecode.iterm2",
+ "dev.warp.Warp-Stable",
+ ]
+
+ public static func adapt(
+ _ request: LocalASRBiasRequest,
+ lexicon: BuiltinLexiconIndex = .shared
+ ) -> LocalASRBiasPayload {
+ let capabilities = request.capabilities
+ let dictionary = request.dictionary
+
+ var selectedSources = ["user"]
+ let preferredSources = Self.preferredLexiconSources(for: request.frontAppBundleId)
+ if preferredSources != nil {
+ selectedSources.append("builtin-computer")
+ } else {
+ selectedSources.append("builtin-top")
+ }
+
+ let userSorted = dictionary.effectiveEntries.sorted { $0.usageCount > $1.usageCount }
+ var mergedTerms: [String] = []
+ var seen = Set()
+ func appendTerm(_ term: String) {
+ let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return }
+ let key = trimmed.lowercased()
+ guard seen.insert(key).inserted else { return }
+ mergedTerms.append(trimmed)
+ }
+
+ for entry in userSorted {
+ appendTerm(entry.term)
+ }
+ let userTermCount = mergedTerms.count
+
+ let builtinWords = lexicon.topTerms(
+ limit: request.builtinASRLimit,
+ minimumWeight: 4,
+ preferredSources: preferredSources
+ )
+ let beforeBuiltin = mergedTerms.count
+ for word in builtinWords {
+ appendTerm(word)
+ }
+ let builtinTermCount = mergedTerms.count - beforeBuiltin
+
+ var hardHotwords: [String] = []
+ switch capabilities.hotwordMode {
+ case .perRequest, .recognizerScoped:
+ let cap = max(capabilities.maxHotwordCount, 1)
+ hardHotwords = Self.hardHotwordList(from: mergedTerms, maxCount: cap)
+ case .cloudVocabulary:
+ hardHotwords = dictionary.asrHotwords(maxCount: max(capabilities.maxHotwordCount, 1))
+ case .none, .promptOnly:
+ break
+ }
+
+ var promptBias: String?
+ var truncated = false
+ var truncationReason: String?
+
+ if capabilities.hotwordMode == .promptOnly, capabilities.maxPromptCharacters > 0 {
+ let built = Self.buildPromptBias(
+ dictionary: dictionary,
+ builtinTerms: builtinWords,
+ maxCharacters: capabilities.maxPromptCharacters
+ )
+ if built.count > capabilities.maxPromptCharacters {
+ truncated = true
+ truncationReason = "promptBias exceeded \(capabilities.maxPromptCharacters) characters"
+ }
+ promptBias = built.isEmpty ? nil : built
+ }
+
+ let polishFragment = Self.buildPolishFragment(
+ dictionary: dictionary,
+ builtinTerms: builtinWords,
+ maxTerms: request.builtinPolishLimit
+ )
+
+ let correctionPairs = dictionary.localCorrectionPairs()
+
+ return LocalASRBiasPayload(
+ hardHotwords: hardHotwords,
+ promptBias: promptBias,
+ corpusContext: promptBias,
+ polishFragment: polishFragment,
+ correctionPairs: correctionPairs,
+ diagnostics: LocalASRBiasDiagnostics(
+ userTermCount: userTermCount,
+ builtinTermCount: builtinTermCount,
+ truncated: truncated,
+ truncationReason: truncationReason,
+ selectedSources: selectedSources
+ )
+ )
+ }
+
+ // MARK: - Private
+
+ private static func preferredLexiconSources(for bundleId: String?) -> Set? {
+ guard let bundleId, codeEditorBundleIDs.contains(bundleId) else { return nil }
+ return ["computer_terms"]
+ }
+
+ private static func hardHotwordList(from terms: [String], maxCount: Int) -> [String] {
+ Array(terms.prefix(maxCount))
+ }
+
+ private static func buildPromptBias(
+ dictionary: PersonalDictionary,
+ builtinTerms: [String],
+ maxCharacters: Int
+ ) -> String {
+ let userBias = dictionary.asrPromptBias(maxCharacters: maxCharacters)
+ let userTermsLower = Set(dictionary.effectiveEntries.map { $0.term.lowercased() })
+ let extras = builtinTerms.filter { !userTermsLower.contains($0.lowercased()) }
+ guard !extras.isEmpty else { return userBias }
+
+ let extraBlock = "常见技术词汇:\(extras.prefix(80).joined(separator: "、"))"
+ if userBias.isEmpty {
+ return String(extraBlock.prefix(maxCharacters))
+ }
+ let combined = userBias + ";" + extraBlock
+ return String(combined.prefix(maxCharacters))
+ }
+
+ private static func buildPolishFragment(
+ dictionary: PersonalDictionary,
+ builtinTerms: [String],
+ maxTerms: Int
+ ) -> String {
+ let userTermsLower = Set(dictionary.effectiveEntries.map { $0.term.lowercased() })
+ let extras = builtinTerms
+ .filter { !userTermsLower.contains($0.lowercased()) }
+ .prefix(maxTerms)
+ guard !extras.isEmpty else { return "" }
+ return "内置技术词汇参考(需原样保留):\(extras.joined(separator: "、"))"
+ }
+}
diff --git a/OSGKeyboardShared/Services/LocalASRBiasDiagnosticsStore.swift b/OSGKeyboardShared/Services/LocalASRBiasDiagnosticsStore.swift
new file mode 100644
index 0000000..8461b54
--- /dev/null
+++ b/OSGKeyboardShared/Services/LocalASRBiasDiagnosticsStore.swift
@@ -0,0 +1,56 @@
+// LocalASRBiasDiagnosticsStore.swift
+// OSGKeyboard · Shared
+//
+// Persists the most recent local ASR bias diagnostics for settings / debug UI.
+
+import Foundation
+
+public struct LocalASRBiasDiagnosticsSnapshot: Codable, Sendable, Equatable {
+ public var capturedAt: Date
+ public var modelId: String?
+ public var backendLabel: String?
+ public var diagnostics: LocalASRBiasDiagnostics
+ public var hotwordCount: Int
+ public var promptBiasLength: Int
+
+ public init(
+ capturedAt: Date = Date(),
+ modelId: String? = nil,
+ backendLabel: String? = nil,
+ diagnostics: LocalASRBiasDiagnostics,
+ hotwordCount: Int = 0,
+ promptBiasLength: Int = 0
+ ) {
+ self.capturedAt = capturedAt
+ self.modelId = modelId
+ self.backendLabel = backendLabel
+ self.diagnostics = diagnostics
+ self.hotwordCount = hotwordCount
+ self.promptBiasLength = promptBiasLength
+ }
+}
+
+public enum LocalASRBiasDiagnosticsStore {
+ private static let defaultsKey = "mac.localASR.lastBiasDiagnostics"
+
+ public static func save(payload: LocalASRBiasPayload, modelId: String?, backendLabel: String?) {
+ let snapshot = LocalASRBiasDiagnosticsSnapshot(
+ modelId: modelId,
+ backendLabel: backendLabel,
+ diagnostics: payload.diagnostics,
+ hotwordCount: payload.hardHotwords.count,
+ promptBiasLength: payload.promptBias?.count ?? 0
+ )
+ guard let data = try? JSONEncoder().encode(snapshot) else { return }
+ UserDefaults.standard.set(data, forKey: defaultsKey)
+ }
+
+ public static func load() -> LocalASRBiasDiagnosticsSnapshot? {
+ guard let data = UserDefaults.standard.data(forKey: defaultsKey) else { return nil }
+ return try? JSONDecoder().decode(LocalASRBiasDiagnosticsSnapshot.self, from: data)
+ }
+
+ public static func clear() {
+ UserDefaults.standard.removeObject(forKey: defaultsKey)
+ }
+}
diff --git a/OSGKeyboardShared/Services/LocalASRInstalledManifestIO.swift b/OSGKeyboardShared/Services/LocalASRInstalledManifestIO.swift
new file mode 100644
index 0000000..5aaff4f
--- /dev/null
+++ b/OSGKeyboardShared/Services/LocalASRInstalledManifestIO.swift
@@ -0,0 +1,32 @@
+// LocalASRInstalledManifestIO.swift
+// OSGKeyboard · Shared
+
+import Foundation
+
+public enum LocalASRInstalledManifestIO {
+
+ public static func manifestURL(fileManager: FileManager = .default) -> URL {
+ let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
+ return appSupport
+ .appendingPathComponent("OSGKeyboard/LocalASRModels/installed-manifest.json")
+ }
+
+ public static func load(defaultModelId: String, fileManager: FileManager = .default) -> LocalASRInstalledManifest {
+ let url = manifestURL(fileManager: fileManager)
+ guard let data = try? Data(contentsOf: url),
+ let manifest = try? JSONDecoder().decode(LocalASRInstalledManifest.self, from: data) else {
+ return LocalASRInstalledManifest(selectedModelId: defaultModelId)
+ }
+ return manifest
+ }
+
+ public static func save(_ manifest: LocalASRInstalledManifest, fileManager: FileManager = .default) throws {
+ let url = manifestURL(fileManager: fileManager)
+ try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ encoder.dateEncodingStrategy = .iso8601
+ let data = try encoder.encode(manifest)
+ try data.write(to: url, options: .atomic)
+ }
+}
diff --git a/OSGKeyboardShared/Services/LocalASRModelDownloadClient.swift b/OSGKeyboardShared/Services/LocalASRModelDownloadClient.swift
new file mode 100644
index 0000000..1d5e809
--- /dev/null
+++ b/OSGKeyboardShared/Services/LocalASRModelDownloadClient.swift
@@ -0,0 +1,157 @@
+// LocalASRModelDownloadClient.swift
+// OSGKeyboard · Shared
+//
+// URLSession download with byte-level progress and pause/resume (macOS local model installs).
+
+import Foundation
+
+#if os(macOS)
+
+public struct LocalASRDownloadProgressUpdate: Sendable {
+ public let bytesReceived: Int64
+ public let bytesTotal: Int64
+
+ public var fraction: Double {
+ guard bytesTotal > 0 else { return 0 }
+ return min(1, max(0, Double(bytesReceived) / Double(bytesTotal)))
+ }
+}
+
+/// Controls an in-flight URLSession download; supports pause via resume data.
+public final class LocalASRModelDownloadController: NSObject, URLSessionDownloadDelegate, @unchecked Sendable {
+ private let destinationURL: URL
+ private let onProgress: @Sendable (LocalASRDownloadProgressUpdate) -> Void
+ private lazy var delegateSession: URLSession = {
+ URLSession(configuration: .default, delegate: self, delegateQueue: nil)
+ }()
+
+ private var remoteURL: URL?
+ private var task: URLSessionDownloadTask?
+ private var completionContinuation: CheckedContinuation?
+ private var isPausing = false
+ private var finished = false
+
+ init(
+ destinationURL: URL,
+ onProgress: @escaping @Sendable (LocalASRDownloadProgressUpdate) -> Void
+ ) {
+ self.destinationURL = destinationURL
+ self.onProgress = onProgress
+ super.init()
+ }
+
+ /// Runs until the archive is fully written to `destinationURL` (survives pause/resume).
+ public func download(from remoteURL: URL) async throws {
+ self.remoteURL = remoteURL
+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in
+ completionContinuation = continuation
+ startTask(resumeData: nil)
+ }
+ }
+
+ public func pause() async throws -> Data {
+ guard task != nil, !finished else {
+ throw LocalASRModelManagerError.downloadFailed("No active download to pause.")
+ }
+ return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in
+ isPausing = true
+ task?.cancel(byProducingResumeData: { [weak self] data in
+ guard let self else { return }
+ self.isPausing = false
+ if let data {
+ continuation.resume(returning: data)
+ } else {
+ continuation.resume(throwing: LocalASRModelManagerError.downloadFailed("Pause failed."))
+ }
+ })
+ }
+ }
+
+ /// Continues a paused download; `download(from:)` must still be awaiting.
+ public func resumeFromPause(_ resumeData: Data) {
+ finished = false
+ startTask(resumeData: resumeData)
+ }
+
+ public func cancel() {
+ finished = true
+ task?.cancel()
+ completionContinuation?.resume(throwing: CancellationError())
+ completionContinuation = nil
+ delegateSession.invalidateAndCancel()
+ }
+
+ private func startTask(resumeData: Data?) {
+ if let resumeData {
+ task = delegateSession.downloadTask(withResumeData: resumeData)
+ } else if let remoteURL {
+ task = delegateSession.downloadTask(with: remoteURL)
+ }
+ task?.resume()
+ }
+
+ // MARK: - URLSessionDownloadDelegate
+
+ public func urlSession(
+ _ session: URLSession,
+ downloadTask: URLSessionDownloadTask,
+ didWriteData bytesWritten: Int64,
+ totalBytesWritten: Int64,
+ totalBytesExpectedToWrite: Int64
+ ) {
+ onProgress(
+ LocalASRDownloadProgressUpdate(
+ bytesReceived: totalBytesWritten,
+ bytesTotal: max(totalBytesExpectedToWrite, 1)
+ )
+ )
+ }
+
+ public func urlSession(
+ _ session: URLSession,
+ downloadTask: URLSessionDownloadTask,
+ didFinishDownloadingTo location: URL
+ ) {
+ guard !finished else { return }
+ finished = true
+ do {
+ let fm = FileManager.default
+ if fm.fileExists(atPath: destinationURL.path) {
+ try fm.removeItem(at: destinationURL)
+ }
+ try fm.moveItem(at: location, to: destinationURL)
+ completionContinuation?.resume()
+ } catch {
+ completionContinuation?.resume(
+ throwing: LocalASRModelManagerError.downloadFailed(error.localizedDescription)
+ )
+ }
+ completionContinuation = nil
+ session.finishTasksAndInvalidate()
+ }
+
+ public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
+ guard !finished else { return }
+ if isPausing { return }
+ if let error {
+ finished = true
+ completionContinuation?.resume(
+ throwing: LocalASRModelManagerError.downloadFailed(error.localizedDescription)
+ )
+ completionContinuation = nil
+ session.finishTasksAndInvalidate()
+ }
+ }
+}
+
+public enum LocalASRModelDownloadClient {
+
+ public static func makeController(
+ destinationURL: URL,
+ onProgress: @escaping @Sendable (LocalASRDownloadProgressUpdate) -> Void
+ ) -> LocalASRModelDownloadController {
+ LocalASRModelDownloadController(destinationURL: destinationURL, onProgress: onProgress)
+ }
+}
+
+#endif
diff --git a/OSGKeyboardShared/Services/LocalASRModelInstallState.swift b/OSGKeyboardShared/Services/LocalASRModelInstallState.swift
new file mode 100644
index 0000000..5545c26
--- /dev/null
+++ b/OSGKeyboardShared/Services/LocalASRModelInstallState.swift
@@ -0,0 +1,138 @@
+// LocalASRModelInstallState.swift
+// OSGKeyboard · Shared
+
+import Foundation
+
+public enum LocalASRModelInstallState {
+
+ public static func rootDirectory(fileManager: FileManager = .default) -> URL {
+ let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
+ return appSupport.appendingPathComponent("OSGKeyboard/LocalASRModels", isDirectory: true)
+ }
+
+ public static func installDirectory(for relativePath: String, fileManager: FileManager = .default) -> URL {
+ rootDirectory(fileManager: fileManager).appendingPathComponent(relativePath, isDirectory: true)
+ }
+
+ public static func isInstalled(
+ _ model: LocalASRModelDefinition,
+ manualMLXPath: String?,
+ fileManager: FileManager = .default
+ ) -> Bool {
+ switch model.installKind {
+ case .manual:
+ guard let required = model.requiredRelativeFiles, !required.isEmpty else { return false }
+ let base = URL(fileURLWithPath: manualMLXPath ?? "", isDirectory: true)
+ guard fileManager.fileExists(atPath: base.path) else { return false }
+ return required.allSatisfy { fileManager.fileExists(atPath: base.appendingPathComponent($0).path) }
+ case .archive:
+ guard let relative = model.installRelativePath,
+ let layout = model.layout,
+ let baseName = model.archiveBaseName else { return false }
+ let root = installDirectory(for: relative, fileManager: fileManager)
+ .appendingPathComponent(baseName, isDirectory: true)
+ return validateArchiveModel(at: root, model: model, layout: layout, fileManager: fileManager)
+ case .runtime:
+ return false
+ }
+ }
+
+ public static func modelRootURL(
+ _ model: LocalASRModelDefinition,
+ fileManager: FileManager = .default
+ ) -> URL? {
+ guard model.installKind == .archive,
+ let relative = model.installRelativePath,
+ let baseName = model.archiveBaseName else { return nil }
+ return installDirectory(for: relative, fileManager: fileManager)
+ .appendingPathComponent(baseName, isDirectory: true)
+ }
+
+ public static func resolveRuntimeBinary(
+ runtime: LocalASRRuntimeDefinition,
+ fileManager: FileManager = .default
+ ) -> URL? {
+ let root = installDirectory(for: runtime.installRelativePath, fileManager: fileManager)
+ for candidate in runtime.binaryCandidates {
+ let direct = root.appendingPathComponent(candidate)
+ if fileManager.isExecutableFile(atPath: direct.path) {
+ return direct
+ }
+ }
+ for candidate in runtime.binaryCandidates {
+ let name = (candidate as NSString).lastPathComponent
+ if let found = findExecutable(named: name, under: root, fileManager: fileManager) {
+ return found
+ }
+ }
+ return nil
+ }
+
+ public static func isRuntimeInstalled(
+ _ runtime: LocalASRRuntimeDefinition,
+ fileManager: FileManager = .default
+ ) -> Bool {
+ resolveRuntimeBinary(runtime: runtime, fileManager: fileManager) != nil
+ }
+
+ // MARK: - Private
+
+ private static func validateArchiveModel(
+ at root: URL,
+ model: LocalASRModelDefinition,
+ layout: LocalASRModelLayout,
+ fileManager: FileManager
+ ) -> Bool {
+ switch model.backend {
+ case .sherpaQwen3:
+ guard let conv = layout.convFrontend,
+ let encoder = layout.encoder,
+ let decoder = layout.decoder,
+ let tokenizer = layout.tokenizer else { return false }
+ return fileManager.fileExists(atPath: root.appendingPathComponent(conv).path)
+ && fileManager.fileExists(atPath: root.appendingPathComponent(encoder).path)
+ && fileManager.fileExists(atPath: root.appendingPathComponent(decoder).path)
+ && fileManager.fileExists(atPath: root.appendingPathComponent(tokenizer, isDirectory: true).path)
+ case .sherpaSenseVoice:
+ guard let onnx = layout.senseVoiceModel,
+ let tokens = layout.tokens else { return false }
+ return fileManager.fileExists(atPath: root.appendingPathComponent(onnx).path)
+ && fileManager.fileExists(atPath: root.appendingPathComponent(tokens).path)
+ default:
+ return false
+ }
+ }
+
+ private static func findExecutable(
+ named name: String,
+ under root: URL,
+ fileManager: FileManager
+ ) -> URL? {
+ guard let enumerator = fileManager.enumerator(
+ at: root,
+ includingPropertiesForKeys: [.isExecutableKey],
+ options: [.skipsHiddenFiles]
+ ) else { return nil }
+ for case let url as URL in enumerator {
+ guard url.lastPathComponent == name else { continue }
+ if fileManager.isExecutableFile(atPath: url.path) {
+ return url
+ }
+ }
+ return nil
+ }
+
+ public static func directoryByteCount(at url: URL, fileManager: FileManager = .default) -> Int64 {
+ guard let enumerator = fileManager.enumerator(
+ at: url,
+ includingPropertiesForKeys: [.fileSizeKey],
+ options: [.skipsHiddenFiles]
+ ) else { return 0 }
+ var total: Int64 = 0
+ for case let fileURL as URL in enumerator {
+ let size = (try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
+ total += Int64(size)
+ }
+ return total
+ }
+}
diff --git a/OSGKeyboardShared/Services/LocalASRModelManager.swift b/OSGKeyboardShared/Services/LocalASRModelManager.swift
new file mode 100644
index 0000000..022fbe4
--- /dev/null
+++ b/OSGKeyboardShared/Services/LocalASRModelManager.swift
@@ -0,0 +1,492 @@
+// LocalASRModelManager.swift
+// OSGKeyboard · Shared
+//
+// Installs local ASR model archives and Sherpa runtimes under Application Support.
+// Catalog is bundled; installed state is persisted in `installed-manifest.json`.
+
+import Foundation
+
+public struct LocalASRInstalledManifest: Codable, Sendable, Equatable {
+ public var schemaVersion: Int
+ public var selectedModelId: String
+ public var installedModelIDs: [String]
+ public var installedRuntimeIDs: [String]
+ public var updatedAt: Date
+
+ public init(
+ schemaVersion: Int = 1,
+ selectedModelId: String,
+ installedModelIDs: [String] = [],
+ installedRuntimeIDs: [String] = [],
+ updatedAt: Date = Date()
+ ) {
+ self.schemaVersion = schemaVersion
+ self.selectedModelId = selectedModelId
+ self.installedModelIDs = installedModelIDs
+ self.installedRuntimeIDs = installedRuntimeIDs
+ self.updatedAt = updatedAt
+ }
+}
+
+public enum LocalASRModelInstallPhase: String, Sendable, Equatable {
+ case idle
+ case downloading
+ case paused
+ case extracting
+ case validating
+ case finalizing
+ case failed
+ case completed
+}
+
+public struct LocalASRModelInstallProgress: Sendable, Equatable {
+ public var phase: LocalASRModelInstallPhase
+ public var fraction: Double
+ public var message: String
+ public var bytesReceived: Int64?
+ public var bytesTotal: Int64?
+ public var activeItemId: String?
+
+ public init(
+ phase: LocalASRModelInstallPhase,
+ fraction: Double,
+ message: String,
+ bytesReceived: Int64? = nil,
+ bytesTotal: Int64? = nil,
+ activeItemId: String? = nil
+ ) {
+ self.phase = phase
+ self.fraction = fraction
+ self.message = message
+ self.bytesReceived = bytesReceived
+ self.bytesTotal = bytesTotal
+ self.activeItemId = activeItemId
+ }
+
+ public static let idle = LocalASRModelInstallProgress(phase: .idle, fraction: 0, message: "")
+}
+
+public enum LocalASRModelManagerError: Error, LocalizedError {
+ case downloadFailed(String)
+ case extractFailed(String)
+ case validationFailed(String)
+ case runtimeMissing
+ case binaryMissing
+
+ public var errorDescription: String? {
+ switch self {
+ case .downloadFailed(let detail): return "Download failed: \(detail)"
+ case .extractFailed(let detail): return "Extract failed: \(detail)"
+ case .validationFailed(let detail): return "Validation failed: \(detail)"
+ case .runtimeMissing: return "Sherpa runtime is not installed."
+ case .binaryMissing: return "Sherpa binary not found in runtime bundle."
+ }
+ }
+}
+
+public actor LocalASRModelManager {
+
+ public static let shared = LocalASRModelManager()
+
+ private let fileManager = FileManager.default
+ private var progress = LocalASRModelInstallProgress.idle
+ #if os(macOS)
+ private var activeDownloadController: LocalASRModelDownloadController?
+ private var pausedResumeData: Data?
+ #endif
+
+ private init() {}
+
+ public func currentProgress() -> LocalASRModelInstallProgress {
+ progress
+ }
+
+ #if os(macOS)
+ public func pauseDownload() async throws {
+ guard progress.phase == .downloading, let controller = activeDownloadController else { return }
+ let resumeData = try await controller.pause()
+ pausedResumeData = resumeData
+ progress = LocalASRModelInstallProgress(
+ phase: .paused,
+ fraction: progress.fraction,
+ message: progress.message,
+ bytesReceived: progress.bytesReceived,
+ bytesTotal: progress.bytesTotal,
+ activeItemId: progress.activeItemId
+ )
+ }
+
+ public func resumeDownload() async throws {
+ guard progress.phase == .paused,
+ let resumeData = pausedResumeData,
+ let controller = activeDownloadController else {
+ throw LocalASRModelManagerError.downloadFailed("No paused download to resume.")
+ }
+ pausedResumeData = nil
+ progress = LocalASRModelInstallProgress(
+ phase: .downloading,
+ fraction: progress.fraction,
+ message: progress.message,
+ bytesReceived: progress.bytesReceived,
+ bytesTotal: progress.bytesTotal,
+ activeItemId: progress.activeItemId
+ )
+ controller.resumeFromPause(resumeData)
+ }
+
+ public func isDownloadPaused() -> Bool {
+ progress.phase == .paused
+ }
+ #endif
+
+ public func rootDirectory() -> URL {
+ let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
+ return appSupport.appendingPathComponent("OSGKeyboard/LocalASRModels", isDirectory: true)
+ }
+
+ public func manifestURL() -> URL {
+ LocalASRInstalledManifestIO.manifestURL(fileManager: fileManager)
+ }
+
+ public func loadManifest(defaultModelId: String) -> LocalASRInstalledManifest {
+ LocalASRInstalledManifestIO.load(defaultModelId: defaultModelId, fileManager: fileManager)
+ }
+
+ public func saveManifest(_ manifest: LocalASRInstalledManifest) throws {
+ try LocalASRInstalledManifestIO.save(manifest, fileManager: fileManager)
+ }
+
+ public func setSelectedModelId(_ modelId: String, catalog: LocalASRCatalogDocument) throws {
+ var manifest = loadManifest(defaultModelId: catalog.defaultModelId)
+ manifest.selectedModelId = modelId
+ manifest.updatedAt = Date()
+ try saveManifest(manifest)
+ }
+
+ public func installDirectory(for relativePath: String) -> URL {
+ rootDirectory().appendingPathComponent(relativePath, isDirectory: true)
+ }
+
+ public func isModelInstalled(_ model: LocalASRModelDefinition, manualMLXPath: String?) -> Bool {
+ LocalASRModelInstallState.isInstalled(model, manualMLXPath: manualMLXPath, fileManager: fileManager)
+ }
+
+ public func isRuntimeInstalled(_ runtime: LocalASRRuntimeDefinition) -> Bool {
+ LocalASRModelInstallState.isRuntimeInstalled(runtime, fileManager: fileManager)
+ }
+
+ #if os(macOS)
+ public func resolveRuntimeBinary(runtime: LocalASRRuntimeDefinition) -> URL? {
+ LocalASRModelInstallState.resolveRuntimeBinary(runtime: runtime, fileManager: fileManager)
+ }
+
+ public func installModel(
+ _ model: LocalASRModelDefinition,
+ catalog: LocalASRCatalogDocument
+ ) async throws {
+ guard model.installKind == .archive,
+ let relative = model.installRelativePath,
+ let baseName = model.archiveBaseName,
+ let sources = model.sources,
+ !sources.isEmpty else {
+ throw LocalASRModelManagerError.validationFailed("Model is not downloadable.")
+ }
+
+ progress = LocalASRModelInstallProgress(
+ phase: .downloading,
+ fraction: 0.05,
+ message: model.displayName,
+ activeItemId: model.id
+ )
+ if model.backend == .sherpaQwen3 || model.backend == .sherpaSenseVoice {
+ try await ensureRuntimeInstalled(catalog: catalog)
+ }
+ let sortedSources = sources.sorted { $0.priority < $1.priority }
+ var lastError: Error?
+ for source in sortedSources {
+ do {
+ try await installArchive(
+ from: source.url,
+ installRelativePath: relative,
+ archiveBaseName: baseName,
+ layoutModel: model,
+ itemId: model.id,
+ displayName: model.displayName
+ )
+ var manifest = loadManifest(defaultModelId: catalog.defaultModelId)
+ if !manifest.installedModelIDs.contains(model.id) {
+ manifest.installedModelIDs.append(model.id)
+ }
+ manifest.updatedAt = Date()
+ try saveManifest(manifest)
+ progress = LocalASRModelInstallProgress(
+ phase: .completed,
+ fraction: 1,
+ message: model.displayName,
+ activeItemId: model.id
+ )
+ return
+ } catch {
+ lastError = error
+ }
+ }
+ progress = LocalASRModelInstallProgress(
+ phase: .failed,
+ fraction: 0,
+ message: lastError?.localizedDescription ?? "Download failed"
+ )
+ throw lastError ?? LocalASRModelManagerError.downloadFailed("All mirrors failed")
+ }
+
+ public func installRuntime(
+ _ runtime: LocalASRRuntimeDefinition,
+ catalog: LocalASRCatalogDocument
+ ) async throws {
+ guard let source = runtime.sources.sorted(by: { $0.priority < $1.priority }).first else {
+ throw LocalASRModelManagerError.downloadFailed("No runtime source configured.")
+ }
+ progress = LocalASRModelInstallProgress(
+ phase: .downloading,
+ fraction: 0.05,
+ message: runtime.displayName,
+ activeItemId: runtime.id
+ )
+ try await installArchive(
+ from: source.url,
+ installRelativePath: runtime.installRelativePath,
+ archiveBaseName: runtime.installRelativePath.split(separator: "/").last.map(String.init) ?? runtime.id,
+ layoutModel: nil,
+ expectedBinaryCandidates: runtime.binaryCandidates,
+ itemId: runtime.id,
+ displayName: runtime.displayName
+ )
+ guard isRuntimeInstalled(runtime) else {
+ throw LocalASRModelManagerError.binaryMissing
+ }
+ var manifest = loadManifest(defaultModelId: catalog.defaultModelId)
+ if !manifest.installedRuntimeIDs.contains(runtime.id) {
+ manifest.installedRuntimeIDs.append(runtime.id)
+ }
+ manifest.updatedAt = Date()
+ try saveManifest(manifest)
+ progress = LocalASRModelInstallProgress(phase: .completed, fraction: 1, message: runtime.displayName)
+ }
+
+ public func modelRootURL(_ model: LocalASRModelDefinition) -> URL? {
+ LocalASRModelInstallState.modelRootURL(model, fileManager: fileManager)
+ }
+
+ public func installDirectoryURL(for model: LocalASRModelDefinition) -> URL? {
+ guard let relative = model.installRelativePath else { return nil }
+ return installDirectory(for: relative)
+ }
+
+ public func deleteModel(
+ _ model: LocalASRModelDefinition,
+ catalog: LocalASRCatalogDocument
+ ) throws {
+ guard model.installKind == .archive, let relative = model.installRelativePath else { return }
+ let dir = installDirectory(for: relative)
+ if fileManager.fileExists(atPath: dir.path) {
+ try fileManager.removeItem(at: dir)
+ }
+ var manifest = loadManifest(defaultModelId: catalog.defaultModelId)
+ manifest.installedModelIDs.removeAll { $0 == model.id }
+ if manifest.selectedModelId == model.id {
+ manifest.selectedModelId = catalog.defaultModelId
+ UserDefaults.standard.set(catalog.defaultModelId, forKey: LocalASRPreferenceKeys.selectedModelId)
+ }
+ manifest.updatedAt = Date()
+ try saveManifest(manifest)
+ if progress.activeItemId == model.id {
+ progress = .idle
+ }
+ }
+
+ public func deleteRuntime(
+ _ runtime: LocalASRRuntimeDefinition,
+ catalog: LocalASRCatalogDocument
+ ) throws {
+ let dir = installDirectory(for: runtime.installRelativePath)
+ if fileManager.fileExists(atPath: dir.path) {
+ try fileManager.removeItem(at: dir)
+ }
+ var manifest = loadManifest(defaultModelId: catalog.defaultModelId)
+ manifest.installedRuntimeIDs.removeAll { $0 == runtime.id }
+ manifest.updatedAt = Date()
+ try saveManifest(manifest)
+ }
+
+ private func setProgress(_ update: LocalASRModelInstallProgress) {
+ progress = update
+ }
+
+ func updateDownloadProgress(
+ itemId: String,
+ displayName: String,
+ update: LocalASRDownloadProgressUpdate
+ ) {
+ reportDownloadProgress(itemId: itemId, displayName: displayName, update: update)
+ }
+
+ private func reportDownloadProgress(
+ itemId: String,
+ displayName: String,
+ update: LocalASRDownloadProgressUpdate
+ ) {
+ // Download phase occupies 10%–55% of the overall install bar.
+ let mapped = 0.10 + update.fraction * 0.45
+ progress = LocalASRModelInstallProgress(
+ phase: .downloading,
+ fraction: mapped,
+ message: displayName,
+ bytesReceived: update.bytesReceived,
+ bytesTotal: update.bytesTotal,
+ activeItemId: itemId
+ )
+ }
+ #endif
+
+ // MARK: - Private
+
+ #if os(macOS)
+ private func installArchive(
+ from urlString: String,
+ installRelativePath: String,
+ archiveBaseName: String,
+ layoutModel: LocalASRModelDefinition?,
+ expectedBinaryCandidates: [String]? = nil,
+ itemId: String,
+ displayName: String
+ ) async throws {
+ guard let remoteURL = URL(string: urlString) else {
+ throw LocalASRModelManagerError.downloadFailed("Invalid URL")
+ }
+
+ let stagingRoot = rootDirectory().appendingPathComponent("staging/\(UUID().uuidString)", isDirectory: true)
+ let destinationParent = installDirectory(for: installRelativePath)
+ try fileManager.createDirectory(at: stagingRoot, withIntermediateDirectories: true)
+ defer { try? fileManager.removeItem(at: stagingRoot) }
+
+ let archiveURL = stagingRoot.appendingPathComponent(remoteURL.lastPathComponent)
+ progress = LocalASRModelInstallProgress(
+ phase: .downloading,
+ fraction: 0.10,
+ message: displayName,
+ activeItemId: itemId
+ )
+
+ do {
+ let controller = LocalASRModelDownloadClient.makeController(destinationURL: archiveURL) { update in
+ Task {
+ await LocalASRModelManager.shared.updateDownloadProgress(
+ itemId: itemId,
+ displayName: displayName,
+ update: update
+ )
+ }
+ }
+ activeDownloadController = controller
+ try await controller.download(from: remoteURL)
+ activeDownloadController = nil
+ pausedResumeData = nil
+ } catch {
+ activeDownloadController = nil
+ pausedResumeData = nil
+ throw LocalASRModelManagerError.downloadFailed(error.localizedDescription)
+ }
+
+ progress = LocalASRModelInstallProgress(
+ phase: .extracting,
+ fraction: 0.58,
+ message: displayName,
+ activeItemId: itemId
+ )
+ try fileManager.createDirectory(at: destinationParent, withIntermediateDirectories: true)
+ let extractOK = try await extractTarBz2(archiveURL: archiveURL, destination: destinationParent)
+ guard extractOK else {
+ throw LocalASRModelManagerError.extractFailed("tar extraction failed")
+ }
+
+ progress = LocalASRModelInstallProgress(
+ phase: .validating,
+ fraction: 0.82,
+ message: displayName,
+ activeItemId: itemId
+ )
+ if let layoutModel {
+ guard LocalASRModelInstallState.isInstalled(
+ layoutModel,
+ manualMLXPath: nil,
+ fileManager: fileManager
+ ) else {
+ throw LocalASRModelManagerError.validationFailed("Required model files missing after extract.")
+ }
+ }
+ if let expectedBinaryCandidates {
+ let runtimeRoot = destinationParent
+ let found = expectedBinaryCandidates.contains { candidate in
+ let direct = runtimeRoot.appendingPathComponent(candidate)
+ if fileManager.isExecutableFile(atPath: direct.path) { return true }
+ let name = (candidate as NSString).lastPathComponent
+ return findExecutable(named: name, under: runtimeRoot) != nil
+ }
+ guard found else {
+ throw LocalASRModelManagerError.binaryMissing
+ }
+ }
+
+ progress = LocalASRModelInstallProgress(
+ phase: .finalizing,
+ fraction: 0.95,
+ message: displayName,
+ activeItemId: itemId
+ )
+ try? fileManager.removeItem(at: archiveURL)
+ }
+
+ public func ensureRuntimeInstalled(catalog: LocalASRCatalogDocument) async throws {
+ guard let runtime = LocalASRModelCatalog.runtime(
+ for: LocalASRModelCatalog.currentRuntimePlatform(),
+ in: catalog
+ ) else {
+ throw LocalASRModelManagerError.runtimeMissing
+ }
+ if isRuntimeInstalled(runtime) { return }
+ try await installRuntime(runtime, catalog: catalog)
+ }
+
+ private func findExecutable(named name: String, under root: URL) -> URL? {
+ guard let enumerator = fileManager.enumerator(
+ at: root,
+ includingPropertiesForKeys: [.isExecutableKey],
+ options: [.skipsHiddenFiles]
+ ) else { return nil }
+ for case let url as URL in enumerator {
+ guard url.lastPathComponent == name else { continue }
+ if fileManager.isExecutableFile(atPath: url.path) {
+ return url
+ }
+ }
+ return nil
+ }
+
+ private func extractTarBz2(archiveURL: URL, destination: URL) async throws -> Bool {
+ try await withCheckedThrowingContinuation { continuation in
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/tar")
+ process.arguments = ["-xjf", archiveURL.path, "-C", destination.path]
+ process.standardOutput = FileHandle.nullDevice
+ process.standardError = FileHandle.nullDevice
+ process.terminationHandler = { proc in
+ continuation.resume(returning: proc.terminationStatus == 0)
+ }
+ do {
+ try process.run()
+ } catch {
+ continuation.resume(throwing: LocalASRModelManagerError.extractFailed(error.localizedDescription))
+ }
+ }
+ }
+ #endif
+}
diff --git a/OSGKeyboardShared/Services/LocalASRPreferenceKeys.swift b/OSGKeyboardShared/Services/LocalASRPreferenceKeys.swift
new file mode 100644
index 0000000..12bef80
--- /dev/null
+++ b/OSGKeyboardShared/Services/LocalASRPreferenceKeys.swift
@@ -0,0 +1,8 @@
+// LocalASRPreferenceKeys.swift
+// OSGKeyboard · Shared
+
+import Foundation
+
+enum LocalASRPreferenceKeys {
+ static let selectedModelId = "mac.localASR.selectedModelId"
+}
diff --git a/OSGKeyboardShared/Services/LocalASRTranscriptCorrector.swift b/OSGKeyboardShared/Services/LocalASRTranscriptCorrector.swift
new file mode 100644
index 0000000..2905de7
--- /dev/null
+++ b/OSGKeyboardShared/Services/LocalASRTranscriptCorrector.swift
@@ -0,0 +1,68 @@
+// LocalASRTranscriptCorrector.swift
+// OSGKeyboard · Shared
+//
+// Deterministic alias → canonical term replacement between raw ASR output
+// and the LLM polish step. Only applies whole-phrase matches.
+
+import Foundation
+
+public enum LocalASRTranscriptCorrector {
+
+ /// Applies high-confidence alias replacements (longest match first).
+ public static func apply(
+ _ text: String,
+ pairs: [LocalASRCorrectionPair]
+ ) -> String {
+ guard !text.isEmpty, !pairs.isEmpty else { return text }
+
+ let sorted = 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
+ }
+
+ var result = text
+ for pair in sorted {
+ result = replaceWholeMatches(
+ in: result,
+ alias: pair.alias,
+ term: pair.term
+ )
+ }
+ return result
+ }
+
+ // MARK: - Private
+
+ private static func replaceWholeMatches(
+ in text: String,
+ alias: String,
+ term: String
+ ) -> String {
+ guard !alias.isEmpty, alias != term else { return text }
+
+ if alias.unicodeScalars.allSatisfy({ $0.isASCII }) {
+ return replaceASCIIWord(in: text, alias: alias, term: term)
+ }
+ return text.replacingOccurrences(of: alias, with: term)
+ }
+
+ private static func replaceASCIIWord(
+ in text: String,
+ alias: String,
+ term: String
+ ) -> String {
+ let escaped = NSRegularExpression.escapedPattern(for: alias)
+ let pattern = "(?i)(? String {
let dictionary = store.personalDictionary
- let dictionaryBlock = dictionary.promptFragment()
+ let dictionaryBlock = Self.mergedDictionaryBlock(
+ dictionary: dictionary,
+ supplement: context.dictionarySupplement
+ )
let contextGuideline = context.appContext.polishGuideline
let intensityGuideline = context.intensity.promptGuideline
let contract = Self.globalOutputContract(useChinese: shouldUseChineseGuidance(providerId: providerId))
@@ -321,6 +324,17 @@ public actor PolishingService {
}
}
+ internal static func mergedDictionaryBlock(
+ dictionary: PersonalDictionary,
+ supplement: String?
+ ) -> String {
+ let base = dictionary.promptFragment()
+ let extra = supplement?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ if base.isEmpty { return extra }
+ if extra.isEmpty { return base }
+ return base + "\n" + extra
+ }
+
private func shouldUseChineseGuidance(providerId: String) -> Bool {
switch providerId {
case "zhipu", "moonshot", "qwen", "deepseek":
diff --git a/OSGKeyboardShared/en.lproj/Shared.strings b/OSGKeyboardShared/en.lproj/Shared.strings
index 02b30f9..218bd11 100644
--- a/OSGKeyboardShared/en.lproj/Shared.strings
+++ b/OSGKeyboardShared/en.lproj/Shared.strings
@@ -157,8 +157,8 @@
"mac.settings.recognition" = "RECOGNITION METHOD";
"mac.settings.cloudEngine" = "Cloud Engine & AI Refinement";
"mac.settings.cloudEngineDesc" = "Premium transcription via your provider's API, plus AI grammar and style polishing.";
-"mac.settings.localEngine" = "Local Recognition (Qwen3-ASR)";
-"mac.settings.localEngineDesc" = "On-device ASR with Qwen3-ASR 1.7B (MLX). High privacy, zero latency.";
+"mac.settings.localEngine" = "Local Recognition";
+"mac.settings.localEngineDesc" = "On-device transcription with a local model. High privacy, zero latency.";
"mac.settings.localSpeechFallback" = "Local Recognition (Apple Speech)";
"mac.settings.localSpeechFallbackDesc" = "On-device Apple Speech when the Qwen3 model is not installed.";
"mac.settings.about" = "About";
@@ -174,6 +174,9 @@
"mac.settings.qwen3ModelDesc" = "Folder with config.json, model.safetensors, vocab.json, and merges.txt.";
"mac.settings.qwen3Browse" = "Choose folder…";
"mac.settings.qwen3Missing" = "Qwen3 model not found — using Apple Speech for now.";
+"mac.settings.mlxModelMissing" = "Select a Qwen3 MLX model folder below, or choose another installed model.";
+"mac.settings.selectedModelMissing" = "%@ is not installed — using Apple Speech for now.";
+"mac.settings.localModelFallbackApple" = "No local model is ready — using Apple Speech for now.";
"mac.settings.accessibility" = "Accessibility";
"mac.settings.accessibilityDesc" = "Required for global shortcut and auto-paste.";
"mac.settings.openAccessibility" = "Open System Settings";
@@ -188,6 +191,43 @@
"mac.error.qwen3ModelMissing" = "Qwen3-ASR model not installed";
"mac.error.qwen3LoadFailed" = "Failed to load Qwen3 model: %@";
"mac.error.qwen3InferenceFailed" = "Qwen3 transcription failed: %@";
+"mac.localASR.models" = "Local ASR Models";
+"mac.localASR.modelsDesc" = "Sherpa models download directly. For MLX Qwen3, drop your converted weights into the folder opened by “Open folder”. All three share one storage directory.";
+"mac.localASR.download" = "Download";
+"mac.localASR.selectFolder" = "Choose folder";
+"mac.localASR.openFolder" = "Open folder";
+"mac.localASR.pause" = "Pause";
+"mac.localASR.resume" = "Resume";
+"mac.localASR.needsFolder" = "Folder required";
+"mac.localASR.installDone" = "Install completed.";
+"mac.localASR.installed" = "Installed";
+"mac.localASR.notInstalled" = "Not installed";
+"mac.localASR.hotwordsYes" = "Hotwords";
+"mac.localASR.hotwordsNo" = "No hotwords";
+"mac.localASR.catalogMissing" = "Local ASR catalog is missing from the app bundle.";
+"mac.localASR.diagnostics" = "Last Bias Diagnostics";
+"mac.localASR.diagnosticsDesc" = "Captured after your most recent local dictation.";
+"mac.localASR.diagEmpty" = "No local dictation yet.";
+"mac.localASR.diagBackend" = "Backend";
+"mac.localASR.diagUserTerms" = "User terms";
+"mac.localASR.diagBuiltinTerms" = "Builtin terms";
+"mac.localASR.diagHotwords" = "Hotwords sent";
+"mac.localASR.diagPrompt" = "Prompt chars";
+"mac.localASR.diagTruncated" = "Prompt truncated";
+"mac.localASR.delete" = "Delete";
+"mac.localASR.deleteDone" = "Model deleted.";
+"mac.localASR.redownload" = "Re-download";
+"mac.localASR.revealInFinder" = "Reveal in Finder";
+"mac.localASR.openStorage" = "Open model storage folder";
+"mac.localASR.runtime" = "Sherpa Runtime";
+"mac.localASR.runtimeDesc" = "Required for Sherpa Qwen3 and SenseVoice models. Installed automatically with those models.";
+"mac.localASR.phase.downloading" = "Downloading";
+"mac.localASR.phase.paused" = "Paused";
+"mac.localASR.phase.extracting" = "Extracting";
+"mac.localASR.phase.validating" = "Validating";
+"mac.localASR.phase.finalizing" = "Finalizing";
+"mac.localASR.phase.failed" = "Failed";
+"mac.localASR.phase.completed" = "Completed";
"mac.error.accessibilityRequired" = "Enable Accessibility for OSGKeyboard in System Settings";
"mac.foregroundApp" = "Front app: %@";
"mac.sync.settingsTitle" = "iCloud Sync";
diff --git a/OSGKeyboardShared/zh-Hans.lproj/Shared.strings b/OSGKeyboardShared/zh-Hans.lproj/Shared.strings
index 0aa9f21..5562931 100644
--- a/OSGKeyboardShared/zh-Hans.lproj/Shared.strings
+++ b/OSGKeyboardShared/zh-Hans.lproj/Shared.strings
@@ -157,8 +157,8 @@
"mac.settings.recognition" = "识别方式";
"mac.settings.cloudEngine" = "云端引擎与 AI 润色";
"mac.settings.cloudEngineDesc" = "通过服务商 API 进行高质量转写,并自动润色语法与风格。";
-"mac.settings.localEngine" = "本地识别(Qwen3-ASR)";
-"mac.settings.localEngineDesc" = "使用 Qwen3-ASR 1.7B(MLX)本地转写,高隐私、低延迟。";
+"mac.settings.localEngine" = "本地识别";
+"mac.settings.localEngineDesc" = "在本机使用本地模型转写,高隐私、低延迟。";
"mac.settings.localSpeechFallback" = "本地识别(Apple Speech)";
"mac.settings.localSpeechFallbackDesc" = "未安装 Qwen3 模型时,使用 Apple 本地语音识别。";
"mac.settings.about" = "关于";
@@ -174,6 +174,9 @@
"mac.settings.qwen3ModelDesc" = "需包含 config.json、model.safetensors、vocab.json 与 merges.txt。";
"mac.settings.qwen3Browse" = "选择文件夹…";
"mac.settings.qwen3Missing" = "未找到 Qwen3 模型,暂时使用 Apple Speech。";
+"mac.settings.mlxModelMissing" = "请在下方选择 Qwen3 MLX 模型目录,或改用其他已安装的模型。";
+"mac.settings.selectedModelMissing" = "「%@」尚未安装,暂时使用 Apple Speech。";
+"mac.settings.localModelFallbackApple" = "没有可用的本地模型,暂时使用 Apple Speech。";
"mac.settings.accessibility" = "辅助功能";
"mac.settings.accessibilityDesc" = "全局快捷键与自动粘贴需要此权限。";
"mac.settings.openAccessibility" = "打开系统设置";
@@ -188,6 +191,43 @@
"mac.error.qwen3ModelMissing" = "未安装 Qwen3-ASR 模型";
"mac.error.qwen3LoadFailed" = "Qwen3 模型加载失败:%@";
"mac.error.qwen3InferenceFailed" = "Qwen3 转写失败:%@";
+"mac.localASR.models" = "本地 ASR 模型";
+"mac.localASR.modelsDesc" = "Sherpa 模型可直接下载;MLX Qwen3 请将转换好的权重放入「打开目录」指向的文件夹。三个模型共用同一存储目录。";
+"mac.localASR.download" = "下载";
+"mac.localASR.selectFolder" = "选择目录";
+"mac.localASR.openFolder" = "打开目录";
+"mac.localASR.pause" = "暂停";
+"mac.localASR.resume" = "继续";
+"mac.localASR.needsFolder" = "需选择目录";
+"mac.localASR.installDone" = "安装完成。";
+"mac.localASR.installed" = "已安装";
+"mac.localASR.notInstalled" = "未安装";
+"mac.localASR.hotwordsYes" = "支持热词";
+"mac.localASR.hotwordsNo" = "无热词";
+"mac.localASR.catalogMissing" = "应用包内缺少本地 ASR 模型目录。";
+"mac.localASR.diagnostics" = "最近一次词库诊断";
+"mac.localASR.diagnosticsDesc" = "在上一轮本地听写后记录。";
+"mac.localASR.diagEmpty" = "尚无本地听写记录。";
+"mac.localASR.diagBackend" = "后端";
+"mac.localASR.diagUserTerms" = "用户词条";
+"mac.localASR.diagBuiltinTerms" = "内置词条";
+"mac.localASR.diagHotwords" = "热词数量";
+"mac.localASR.diagPrompt" = "Prompt 字符";
+"mac.localASR.diagTruncated" = "Prompt 已截断";
+"mac.localASR.delete" = "删除";
+"mac.localASR.deleteDone" = "模型已删除。";
+"mac.localASR.redownload" = "重新下载";
+"mac.localASR.revealInFinder" = "在 Finder 中显示";
+"mac.localASR.openStorage" = "打开模型存储目录";
+"mac.localASR.runtime" = "Sherpa 运行时";
+"mac.localASR.runtimeDesc" = "Sherpa Qwen3 与 SenseVoice 模型需要此运行时;下载上述模型时会自动安装。";
+"mac.localASR.phase.downloading" = "下载中";
+"mac.localASR.phase.paused" = "已暂停";
+"mac.localASR.phase.extracting" = "解压中";
+"mac.localASR.phase.validating" = "校验中";
+"mac.localASR.phase.finalizing" = "完成安装";
+"mac.localASR.phase.failed" = "失败";
+"mac.localASR.phase.completed" = "已完成";
"mac.error.accessibilityRequired" = "请在系统设置中为 OSGKeyboard 启用辅助功能";
"mac.foregroundApp" = "前台应用:%@";
"mac.sync.settingsTitle" = "iCloud 同步";
diff --git a/OSGKeyboardTests/FlowSessionBridgeTests.swift b/OSGKeyboardTests/FlowSessionBridgeTests.swift
index 272cf61..286c3f5 100644
--- a/OSGKeyboardTests/FlowSessionBridgeTests.swift
+++ b/OSGKeyboardTests/FlowSessionBridgeTests.swift
@@ -141,6 +141,7 @@ final class FlowSessionBridgeTests: XCTestCase {
func testDarwinNotificationPostsWithoutCrashing() {
FlowSessionDarwin.postSessionChanged()
+ FlowSessionDarwin.postCommandChanged()
FlowSessionDarwin.postHostReadyChanged()
}
@@ -183,4 +184,112 @@ final class FlowSessionBridgeTests: XCTestCase {
XCTAssertFalse(defaults.bool(forKey: FlowSessionKeys.flowHostReady))
XCTAssertFalse(FlowSessionBridge.isHostReady(defaults: defaults))
}
+
+ func testFlowCommandRoundTrip() {
+ let defaults = makeDefaults()
+ let sessionId = UUID()
+ let utteranceId = UUID()
+ let command = FlowCommand(
+ sessionId: sessionId,
+ utteranceId: utteranceId,
+ commandSeq: 42,
+ action: .startRecording,
+ localeId: "zh-Hans",
+ createdAt: 123
+ )
+
+ FlowSessionBridge.writeCommand(command, defaults: defaults)
+
+ XCTAssertEqual(FlowSessionBridge.latestCommand(defaults: defaults), command)
+ }
+
+ func testFlowResultRoundTripPreservesUtteranceIdentity() {
+ let defaults = makeDefaults()
+ let sessionId = UUID()
+ let utteranceId = UUID()
+ let result = FlowResult(
+ sessionId: sessionId,
+ utteranceId: utteranceId,
+ commandSeq: 43,
+ status: .final,
+ text: "hello",
+ warning: "raw fallback",
+ createdAt: 124
+ )
+
+ FlowSessionBridge.writeResult(result, defaults: defaults)
+
+ XCTAssertEqual(FlowSessionBridge.latestResult(defaults: defaults), result)
+ FlowSessionBridge.clearResult(defaults: defaults)
+ XCTAssertNil(FlowSessionBridge.latestResult(defaults: defaults))
+ }
+
+ func testFlowAckRoundTrip() {
+ let defaults = makeDefaults()
+ let ack = FlowAck(
+ sessionId: UUID(),
+ utteranceId: UUID(),
+ commandSeq: 44,
+ consumedAt: 125
+ )
+
+ FlowSessionBridge.writeAck(ack, defaults: defaults)
+
+ XCTAssertEqual(FlowSessionBridge.latestAck(defaults: defaults), ack)
+ }
+
+ func testReadySnapshotDrivesHostReady() {
+ let defaults = makeDefaults()
+ let sessionId = UUID()
+ let now = Date().timeIntervalSince1970
+ FlowSessionBridge.markSessionActive(duration: 60, sessionId: sessionId, defaults: defaults)
+ let snapshot = FlowReadySnapshot(
+ sessionId: sessionId,
+ ready: true,
+ reason: .ready,
+ heartbeatAt: now,
+ readyAt: now,
+ audioProofAt: now,
+ engineMode: "local",
+ localeId: "zh-Hans",
+ sessionExpiresAt: now + 60
+ )
+
+ FlowSessionBridge.writeReadySnapshot(snapshot, defaults: defaults)
+
+ XCTAssertEqual(FlowSessionBridge.readySnapshot(defaults: defaults), snapshot)
+ XCTAssertTrue(FlowSessionBridge.isHostReady(defaults: defaults))
+ }
+
+ func testClearFlowStateRemovesProtocolPayloads() {
+ let defaults = makeDefaults()
+ let sessionId = UUID()
+ let utteranceId = UUID()
+ FlowSessionBridge.writeCommand(
+ FlowCommand(
+ sessionId: sessionId,
+ utteranceId: utteranceId,
+ commandSeq: 1,
+ action: .startRecording,
+ localeId: "en-US"
+ ),
+ defaults: defaults
+ )
+ FlowSessionBridge.writeResult(
+ FlowResult(
+ sessionId: sessionId,
+ utteranceId: utteranceId,
+ commandSeq: 1,
+ status: .partial,
+ text: "hello"
+ ),
+ defaults: defaults
+ )
+
+ FlowSessionBridge.clearFlowState(defaults: defaults)
+
+ XCTAssertNil(FlowSessionBridge.latestCommand(defaults: defaults))
+ XCTAssertNil(FlowSessionBridge.latestResult(defaults: defaults))
+ XCTAssertNil(FlowSessionBridge.readySnapshot(defaults: defaults))
+ }
}
diff --git a/OSGKeyboardTests/LocalASRBiasAdapterTests.swift b/OSGKeyboardTests/LocalASRBiasAdapterTests.swift
new file mode 100644
index 0000000..8fbae04
--- /dev/null
+++ b/OSGKeyboardTests/LocalASRBiasAdapterTests.swift
@@ -0,0 +1,124 @@
+// LocalASRBiasAdapterTests.swift
+// OSGKeyboardTests
+
+import XCTest
+@testable import OSGKeyboardShared
+
+final class LocalASRBiasAdapterTests: XCTestCase {
+
+ private func makeFixtureLexicon() throws -> BuiltinLexiconIndex {
+ let dir = FileManager.default.temporaryDirectory
+ .appendingPathComponent("osg-phrases-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ let url = dir.appendingPathComponent("phrases.tsv")
+ let tsv = """
+ word\tpinyin\tsource\tweight
+ SwiftUI\tswift ui\tcomputer_terms\t5
+ Kubernetes\tku bo ne si\tcomputer_terms\t5
+ 一致性\tyi zhi xing\tcomputer_terms\t5
+ """
+ try tsv.write(to: url, atomically: true, encoding: .utf8)
+ addTeardownBlock {
+ try? FileManager.default.removeItem(at: dir)
+ }
+ return BuiltinLexiconIndex(fixtureURL: url)
+ }
+
+ func testAdaptBuildsPromptBiasForQwen3MLX() throws {
+ let lexicon = try makeFixtureLexicon()
+ var dict = PersonalDictionary.empty
+ _ = dict.upsertManual(term: "Cursor")
+ dict.updateAliases(for: dict.entries[0].id, aliases: ["cursor"])
+
+ let payload = LocalASRBiasAdapter.adapt(
+ LocalASRBiasRequest(
+ dictionary: dict,
+ locale: Locale(identifier: "zh-CN"),
+ capabilities: .qwen3MLX
+ ),
+ lexicon: lexicon
+ )
+
+ XCTAssertNotNil(payload.promptBias)
+ XCTAssertTrue(payload.promptBias?.contains("Cursor") == true)
+ XCTAssertTrue(payload.promptBias?.contains("SwiftUI") == true)
+ XCTAssertEqual(payload.diagnostics.userTermCount, 2) // OSGKeyboard system + Cursor
+ XCTAssertGreaterThan(payload.diagnostics.builtinTermCount, 0)
+ }
+
+ func testAdaptProducesPolishFragmentWithoutUserDuplicates() throws {
+ let lexicon = try makeFixtureLexicon()
+ var dict = PersonalDictionary.empty
+ _ = dict.upsertManual(term: "SwiftUI")
+
+ let payload = LocalASRBiasAdapter.adapt(
+ LocalASRBiasRequest(
+ dictionary: dict,
+ locale: Locale(identifier: "zh-CN"),
+ capabilities: .qwen3MLX
+ ),
+ lexicon: lexicon
+ )
+
+ XCTAssertFalse(payload.polishFragment.contains("SwiftUI"))
+ XCTAssertTrue(payload.polishFragment.contains("Kubernetes"))
+ }
+
+ func testCorrectionPairsFromAliases() {
+ var dict = PersonalDictionary.empty
+ _ = dict.upsertManual(term: "Kubernetes")
+ dict.updateAliases(for: dict.entries[0].id, aliases: ["k8s"])
+
+ let payload = LocalASRBiasAdapter.adapt(
+ LocalASRBiasRequest(
+ dictionary: dict,
+ locale: Locale(identifier: "zh-CN"),
+ capabilities: .qwen3MLX
+ ),
+ lexicon: BuiltinLexiconIndex.shared
+ )
+
+ XCTAssertEqual(payload.correctionPairs.count, 1)
+ XCTAssertEqual(payload.correctionPairs[0].alias, "k8s")
+ XCTAssertEqual(payload.correctionPairs[0].term, "Kubernetes")
+ }
+
+ func testTranscriptCorrectorReplacesASCIIAlias() {
+ let pairs = [LocalASRCorrectionPair(alias: "k8s", term: "Kubernetes")]
+ let result = LocalASRTranscriptCorrector.apply(
+ "部署 k8s 集群",
+ pairs: pairs
+ )
+ XCTAssertEqual(result, "部署 Kubernetes 集群")
+ }
+
+ func testTranscriptCorrectorSkipsPartialASCIIMatch() {
+ let pairs = [LocalASRCorrectionPair(alias: "k8s", term: "Kubernetes")]
+ let result = LocalASRTranscriptCorrector.apply(
+ "xk8s集群",
+ pairs: pairs
+ )
+ XCTAssertEqual(result, "xk8s集群")
+ }
+
+ func testBuiltinLexiconParsesTSV() {
+ let terms = BuiltinLexiconIndex.parseTSV(
+ "word\tpinyin\tsource\tweight\nFoo\tfoo\tcomputer_terms\t5\n"
+ )
+ XCTAssertEqual(terms.count, 1)
+ XCTAssertEqual(terms[0].word, "Foo")
+ XCTAssertEqual(terms[0].weight, 5)
+ }
+
+ func testPolishingServiceMergesDictionarySupplement() {
+ let dict = PersonalDictionary(entries: [
+ PersonalDictionary.Entry(term: "Cursor", category: .productName, source: .manual),
+ ])
+ let merged = PolishingService.mergedDictionaryBlock(
+ dictionary: dict,
+ supplement: "内置技术词汇参考:SwiftUI"
+ )
+ XCTAssertTrue(merged.contains("Cursor"))
+ XCTAssertTrue(merged.contains("SwiftUI"))
+ }
+}
diff --git a/OSGKeyboardTests/LocalASRModelCatalogTests.swift b/OSGKeyboardTests/LocalASRModelCatalogTests.swift
new file mode 100644
index 0000000..4e13d7a
--- /dev/null
+++ b/OSGKeyboardTests/LocalASRModelCatalogTests.swift
@@ -0,0 +1,88 @@
+// LocalASRModelCatalogTests.swift
+// OSGKeyboardTests
+
+import XCTest
+@testable import OSGKeyboardShared
+
+final class LocalASRModelCatalogTests: XCTestCase {
+
+ func testBundledCatalogLoads() throws {
+ let catalog = try LocalASRModelCatalog.loadBundled()
+ XCTAssertEqual(catalog.schemaVersion, 1)
+ XCTAssertFalse(catalog.models.isEmpty)
+ XCTAssertTrue(catalog.models.contains { $0.id == "qwen3-mlx-1.7b" })
+ XCTAssertTrue(catalog.models.contains { $0.id == "sherpa-qwen3-0.6b-int8" })
+ }
+
+ func testCapabilitiesForSherpaQwen3() throws {
+ let catalog = try LocalASRModelCatalog.loadBundled()
+ let model = try XCTUnwrap(LocalASRModelCatalog.model("sherpa-qwen3-0.6b-int8", in: catalog))
+ let caps = LocalASRModelCatalog.capabilities(for: model)
+ XCTAssertEqual(caps.hotwordMode, .recognizerScoped)
+ XCTAssertTrue(model.supportsHotwords)
+ }
+
+ func testManifestRoundTrip() throws {
+ let manifest = LocalASRInstalledManifest(
+ selectedModelId: "sherpa-qwen3-0.6b-int8",
+ installedModelIDs: ["sherpa-qwen3-0.6b-int8"]
+ )
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent("manifest-\(UUID().uuidString).json")
+ defer { try? FileManager.default.removeItem(at: url) }
+
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ try encoder.encode(manifest).write(to: url)
+
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ let loaded = try decoder.decode(LocalASRInstalledManifest.self, from: Data(contentsOf: url))
+ XCTAssertEqual(loaded.selectedModelId, manifest.selectedModelId)
+ XCTAssertEqual(loaded.installedModelIDs, manifest.installedModelIDs)
+ }
+
+ func testBiasDiagnosticsStoreRoundTrip() {
+ LocalASRBiasDiagnosticsStore.clear()
+ let payload = LocalASRBiasPayload(
+ hardHotwords: ["Cursor"],
+ promptBias: "test",
+ corpusContext: nil,
+ polishFragment: "fragment",
+ correctionPairs: [],
+ diagnostics: LocalASRBiasDiagnostics(userTermCount: 2, builtinTermCount: 3)
+ )
+ LocalASRBiasDiagnosticsStore.save(
+ payload: payload,
+ modelId: "qwen3-mlx-1.7b",
+ backendLabel: "MLX"
+ )
+ let snapshot = LocalASRBiasDiagnosticsStore.load()
+ XCTAssertEqual(snapshot?.modelId, "qwen3-mlx-1.7b")
+ XCTAssertEqual(snapshot?.diagnostics.userTermCount, 2)
+ XCTAssertEqual(snapshot?.hotwordCount, 1)
+ LocalASRBiasDiagnosticsStore.clear()
+ }
+
+ func testSherpaAdapterProducesHardHotwords() throws {
+ let fixtureURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent("phrases-\(UUID().uuidString).tsv")
+ try "word\tpinyin\tsource\tweight\nSwiftUI\tswift ui\tcomputer_terms\t5\n"
+ .write(to: fixtureURL, atomically: true, encoding: .utf8)
+ defer { try? FileManager.default.removeItem(at: fixtureURL) }
+
+ var dict = PersonalDictionary.empty
+ _ = dict.upsertManual(term: "Kubernetes")
+
+ let payload = LocalASRBiasAdapter.adapt(
+ LocalASRBiasRequest(
+ dictionary: dict,
+ locale: Locale(identifier: "zh-CN"),
+ capabilities: .sherpaQwen3
+ ),
+ lexicon: BuiltinLexiconIndex(fixtureURL: fixtureURL)
+ )
+ XCTAssertFalse(payload.hardHotwords.isEmpty)
+ XCTAssertTrue(payload.hardHotwords.contains("Kubernetes"))
+ }
+}
diff --git a/docs/local-asr-architecture.md b/docs/local-asr-architecture.md
new file mode 100644
index 0000000..d67bd8f
--- /dev/null
+++ b/docs/local-asr-architecture.md
@@ -0,0 +1,585 @@
+# OSGKeyboard 本地 ASR 技术架构
+
+> **文档状态**:架构规划(非实现规格)
+> **适用范围**:macOS 本地听写;与 iOS 键盘扩展、云 ASR 路径的关系见各节说明。
+> **核心结论**:短期不换主模型,优先打通 **词库感知管道**;中期用 POC 验证 **Sherpa Qwen3 hard hotwords** 是否值得成为热词主线。
+
+---
+
+## 1. Executive Summary
+
+OSGKeyboard 的本地 ASR 竞争力不来自单一模型,而来自:
+
+1. **用户 PersonalDictionary**(term / aliases / iCloud)
+2. **内置技术词库**(`phrases.tsv` ≈ 1 万词,iOS 已用于 Apple CLM)
+3. **分层 bias**:ASR 偏置 → 后处理纠错 → Polish 保真
+4. **可替换的 Local ASR Provider**(Qwen3 MLX 主线,Sherpa / SenseVoice / Apple Speech 对照)
+
+当前最大缺口:**macOS 本地路径未消费任何词库**;云路径已通过 `PersonalDictionary+ASRBias` 完整接线。
+
+推荐路线:
+
+| 阶段 | 动作 |
+|------|------|
+| **短期** | 保留 Qwen3 MLX;实现 `LocalASRBiasAdapter`;接 soft prompt + polish + aliases 后处理 |
+| **中期** | ModelScope 优先的本地模型 catalog;Sherpa Qwen3 hotwords POC |
+| **长期** | 按评测数据决定是否新增默认 provider 或保留 Qwen3 MLX |
+
+---
+
+## 2. 背景与问题定义
+
+### 2.1 为什么本地 ASR 不能只讨论模型
+
+语音输入的「专有名词准确率」由多层共同决定:
+
+- **ASR 层**:听出 `Claude`、`SwiftUI`、`Qwen3-ASR`
+- **后处理层**:`克劳德` → `Claude`
+- **润色层**:保留品牌名、变量名,不擅自改写
+
+闭源产品(Typeless 等)常把词典效果归因于云端 ASR;开源竞品(OpenLess、Typeflux、SayIt)表明:**词典必须按 backend 能力分层注入**,不能假设「一个 hotwords 数组走天下」。
+
+### 2.2 OSG 相对竞品的结构性优势
+
+| 能力 | OSGKeyboard | 典型开源竞品 |
+|------|-------------|----------------|
+| 用户词库 | `PersonalDictionary`(term + aliases + category + iCloud) | 多为 phrase-only |
+| 内置领域词库 | ~10k `phrases.tsv` + iOS CLM | OpenLess preset ~20 词;SayIt server hotwords.txt ~30 词 |
+| 云 ASR bias | 智谱 / 阿里 vocabulary / Whisper prompt | 单云或单 provider |
+| iOS 本地 CLM | `SFCustomLanguageModelData` | macOS 路径未等价 |
+
+### 2.3 设计目标
+
+- 离线、隐私友好的 macOS 本地听写
+- 复用 `PersonalDictionary` 与 `phrases.tsv` **源数据**(非 iOS `.bin` 直用)
+- Provider 可替换;能力矩阵诚实声明(尤其热词模式)
+- 模型下载可管理(**ModelScope 优先**,HF / GitHub 备用)
+- 可评测、可灰度、可回退
+
+### 2.4 非目标
+
+- 不立即将主路径切到 FunASR Python server 或 Sherpa
+- 不把 1 万词全量塞入 ASR prompt
+- 不把 Polish 当作唯一纠错层
+- 不承诺未 POC 验证的模型效果
+- 第一期不强制实现 Typeflux 式「自动词库学习」(仅作可选实验设计)
+
+---
+
+## 3. 当前架构(代码事实)
+
+### 3.1 端到端数据流
+
+```mermaid
+flowchart LR
+ subgraph macOS["macOS"]
+ Rec["MacAudioRecorder"]
+ Pipe["MacDictationPipeline"]
+ Local["MacLocalASRService"]
+ Cloud["CloudASRClient"]
+ Polish["PolishingService"]
+ Insert["MacTextInsertionService"]
+ end
+ Rec --> Pipe
+ Pipe -->|engineMode local| Local
+ Pipe -->|engineMode cloud| Cloud
+ Local --> Polish
+ Cloud --> Polish
+ Polish --> Insert
+```
+
+### 3.2 云路径 vs 本地路径
+
+| 环节 | 云 ASR | 本地 ASR(当前) |
+|------|--------|------------------|
+| 入口 | `MacDictationPipeline.run` | 同左 |
+| ASR | `CloudASRClientFactory` + `dictionary: store.personalDictionary` | `MacLocalASRService.transcribe(samples, locale)` **无 dictionary** |
+| 词库 bias | `PersonalDictionary+ASRBias`(按 provider) | **无** |
+| 润色 | `PolishingService` + `promptFragment()` | 同左(仅用词典做 polish,不经 ASR) |
+| 默认模型 | 用户所选云 provider | Qwen3 MLX 1.7B;缺权重 → Apple Speech |
+
+关键代码:
+
+- [`OSGKeyboardMac/MacDictationPipeline.swift`](../OSGKeyboardMac/MacDictationPipeline.swift) — 本地分支未传 `personalDictionary`
+- [`OSGKeyboardMac/MacLocalASRService.swift`](../OSGKeyboardMac/MacLocalASRService.swift) — Qwen3 / Apple Speech 二选一
+- [`OSGKeyboardShared/Models/PersonalDictionary+ASRBias.swift`](../OSGKeyboardShared/Models/PersonalDictionary+ASRBias.swift) — 云侧 `asrHotwords` / `asrPromptBias` / 阿里热词表
+- [`OSGKeyboardShared/Services/PolishingService.swift`](../OSGKeyboardShared/Services/PolishingService.swift) — 润色层消费 `dictionary.promptFragment()`
+
+### 3.3 iOS 词库资产(macOS 不可直接复用)
+
+- **内置词库**:[`OSGKeyboard/Resources/CustomLanguageModel/v1/phrases.tsv`](../OSGKeyboard/Resources/CustomLanguageModel/v1/phrases.tsv)(约 10,301 行,含 `word` / `pinyin` / `source` / `weight`)
+- **Apple CLM**:[`OSGKeyboardShared/Services/CustomLanguageModelManager.swift`](../OSGKeyboardShared/Services/CustomLanguageModelManager.swift) → `SFSpeechLanguageModel.Configuration`,仅 iOS 26+ 中文本地 ASR 路径
+- **结论**:macOS 需从 TSV + `PersonalDictionary` **重新适配**为 Qwen3 prompt / Sherpa hotwords / polish fragment,不能加载 `OSGKeyboardCLM.bin`
+
+### 3.4 本地模型现状
+
+- 默认路径:`~/Library/Application Support/OSGKeyboard/models/qwen3-asr-1.7b-mlx`
+- 校验:`config.json`、`model.safetensors`、`vocab.json`、`merges.txt`
+- Qwen3 引擎:[`MacQwen3LocalASR.swift`](../OSGKeyboardMac/MacQwen3LocalASR.swift) / [`MacQwen3ASREngine.swift`](../OSGKeyboardMac/MacQwen3ASREngine.swift) — **无 prompt / hotword 入参**
+
+---
+
+## 4. 开源竞品源码观察
+
+基于 GitHub topic `typeless-alternative` 及关联仓库(2026-03 快照)。
+
+### 4.1 对比总表
+
+| 项目 | 技术栈 | 本地 ASR | 热词 / 词库 | 可借鉴 | 不宜照搬 |
+|------|--------|----------|-------------|--------|----------|
+| **OpenLess** | Tauri/Rust | Qwen3 C 引擎、Apple Speech、Sherpa(Win) | 火山 `context.hotwords`;Whisper prompt;Polish hotword block;**本地 Qwen3 未接词典** | 多 provider + polish 双层 | 本地热词叙事过度乐观 |
+| **Typeflux** | Swift/macOS | SenseVoice、FunASR、Qwen3 Sherpa CLI、WhisperKit | `VocabularyStore` cap 500;Doubao hotwords;Whisper prompt;**Sherpa 本地无热词**;自动项目词 + 编辑后学习 | Swift 原生、词库排序、自动学习(作实验) | Sherpa 仅 CLI 离线,无 hotwords 接线 |
+| **SayIt** | Tauri + FastAPI | sherpa-onnx Rust:**Qwen3 recognizer 创建时写 hotwords** | 内置主题词 + 自定义;云 Qwen corpus;server `hotwords.txt` | **本地 Qwen3 hard hotwords 实证**;跨 provider `StartOptions.hotwords` | 服务端 vLLM 非 macOS 客户端主线 |
+| **VoiceSnap** | Go/Wails | SenseVoice + sherpa-onnx | **无个性化词库** | 离线体验:静音截断、剪贴板保护、填充词过滤 | 无万级词库场景 |
+| **OpenBroca** | Electron | Sherpa 等 | Dictionary hotword/replacement | **one-shot `recognize` first**;model catalog、sha256、selected model | Electron 栈 |
+
+### 4.2 对 OSG 的启示
+
+1. **学架构,不学「本地已完整支持热词」的 README 叙事**(OpenLess 本地 Qwen3 缺口与 OSG 类似)。
+2. **SayIt 证明**:sherpa-onnx `OfflineQwen3ASRModelConfig.hotwords` 可在 recognizer 创建时注入;热词变化需 **重建 recognizer**(缓存 key 含 hotwords 字符串)。
+3. **Typeflux 证明**:`activeTerms()` 上限 500 + 动态排序;但 Sherpa Qwen3/SenseVoice 命令行路径**未传** vocabulary prompt。
+4. **OpenBroca 证明**:runtime 不得静默选「目录里第一个模型」;须 `selectedModelId` + manifest。
+
+---
+
+## 5. 目标架构
+
+### 5.1 管道总览
+
+```mermaid
+flowchart TD
+ audioSamples["Audio Samples 16kHz"] --> macPipeline["MacDictationPipeline"]
+ personalDict["PersonalDictionary"] --> biasAdapter["LocalASRBiasAdapter"]
+ phrasesTSV["phrases.tsv Index"] --> biasAdapter
+ runtimeCtx["Runtime Context locale app recentHits"] --> biasAdapter
+ providerCap["Provider Capability"] --> biasAdapter
+
+ biasAdapter --> hotwords["hardHotwords"]
+ biasAdapter --> promptBias["promptBias"]
+ biasAdapter --> corpusText["corpusContext"]
+ biasAdapter --> correctionPairs["correctionPairs"]
+ biasAdapter --> polishFrag["polishFragment"]
+ biasAdapter --> diag["diagnostics"]
+
+ macPipeline --> recognize["LocalASRProvider.recognize"]
+ hotwords --> recognize
+ promptBias --> recognize
+ corpusText --> recognize
+
+ recognize --> rawTranscript["Raw Transcript"]
+ rawTranscript --> correctionLayer["Correction Layer"]
+ correctionPairs --> correctionLayer
+ correctionLayer --> polishingService["PolishingService"]
+ polishFrag --> polishingService
+ polishingService --> finalText["Final Text"]
+```
+
+### 5.2 三层职责边界
+
+| 层 | 职责 | 禁止 |
+|----|------|------|
+| **ASR bias** | 提高听写阶段专有名词概率 | 承担全文语法润色 |
+| **Correction** | 高置信 `aliases → term` 替换 | 凭热词表改写普通句意 |
+| **Polish** | 标点、口语转书面、热词保真 | 单独承担全部专名纠错 |
+
+---
+
+## 6. Local ASR Provider 抽象
+
+### 6.1 One-shot first
+
+借鉴 OpenBroca:macOS 听写主路径为 **录完后一次性 `recognize`**;流式预览(`transcribe` / partial)为可选能力,非第一期必做。
+
+建议协议(概念层):
+
+```swift
+protocol LocalASRProvider {
+ var capabilities: LocalASRCapabilities { get }
+ func recognize(
+ samples: [Float],
+ sampleRate: Int,
+ locale: Locale,
+ bias: LocalASRBiasPayload?,
+ options: LocalASRRecognizeOptions?
+ ) async throws -> LocalASRResult
+}
+```
+
+### 6.2 能力矩阵(须诚实声明)
+
+除 `supportsStreaming`、`maxHotwordCount` 外,**必须区分热词模式**:
+
+| 字段 | 含义 |
+|------|------|
+| `hotwordMode` | `none` / `promptOnly` / `perRequest` / `recognizerScoped` / `cloudVocabulary` |
+| `hotwordStrength` | `weak` / `medium` / `strong`(产品文案用,非科学绝对值) |
+| `hotwordReloadCost` | `none` / `recognizerReload` / `modelReload` |
+| `maxPromptCharacters` | soft prompt 上限 |
+| `maxHotwordCount` | hard hotwords 上限 |
+| `supportsLanguageHint` | 是否接受 locale → language hint |
+
+### 6.3 各 Backend 定位(规划)
+
+| Provider | 角色 | hotwordMode(规划) | 备注 |
+|----------|------|---------------------|------|
+| **Qwen3 MLX** | 短期主线 | `promptOnly`(待接 `qwen_set_prompt` 等价 API) | 已有权重路径;改动面最小 |
+| **Sherpa Qwen3** | 中期 POC | `recognizerScoped` | SayIt 同款;热词变更加载成本 |
+| **SenseVoice** | 对照 | `none` 或弱 prompt | 速度/中文基线;非热词主线 |
+| **Apple Speech** | Fallback | `none`(macOS CLM 待验证) | 系统稳定 |
+| **Cloud ASR** | 质量上限 | 各云 `PersonalDictionary+ASRBias` | 非离线 |
+
+**暂不主推**:Sherpa Paraformer 作为热词主线(官方不支持 Paraformer hotwords,与 transducer/Qwen3 不同)。
+
+---
+
+## 7. LocalASRBiasAdapter 设计
+
+### 7.1 输入
+
+| 输入 | 说明 |
+|------|------|
+| `PersonalDictionary.effectiveEntries` | 用户词;最高优先级 |
+| `BuiltinLexiconIndex` | 自 `phrases.tsv` 构建;按 weight / 场景筛选 |
+| `locale` | `store.localeId` |
+| `frontAppBundleId` | 可选;技术类 App 提升 IT 子集权重 |
+| `recentHitTerms` | 历史命中统计(若已有) |
+| `providerCapabilities` | 决定输出哪些字段、如何截断 |
+
+### 7.2 输出 `LocalASRBiasPayload`
+
+```swift
+struct LocalASRBiasPayload {
+ var hardHotwords: [String] // Sherpa Qwen3、部分云 API
+ var promptBias: String? // Qwen3 MLX、Whisper 系
+ var corpusContext: String? // Qwen 云 corpus 风格(若将来统一)
+ var polishFragment: String // PolishingService 追加块
+ var correctionPairs: [(alias: String, term: String)]
+ var diagnostics: BiasDiagnostics // 供设置页 / 调试
+}
+
+struct BiasDiagnostics {
+ var userTermCount: Int
+ var builtinTermCount: Int
+ var truncated: Bool
+ var truncationReason: String?
+ var selectedSources: [String] // e.g. user, builtin-it, builtin-top
+}
+```
+
+### 7.3 优先级与截断
+
+```
+用户高频 / 最近命中
+ > PersonalDictionary(全部有效 term)
+ > 当前 App 相关内置词(phrases 子集)
+ > 高 weight 内置技术词(Top-N)
+ > 其余内置词(仅 polish / 检索,不进 ASR)
+```
+
+默认建议(可 POC 调参):
+
+| 输出 | 默认上限 |
+|------|----------|
+| `hardHotwords` | 100(Qwen3 Sherpa);对齐 `asrHotwords(maxCount: 100)` |
+| `promptBias` | 800 字符;复用 `asrPromptBias(maxCharacters:)` 逻辑 |
+| ASR 层内置词 | 200–500;**不全量 1 万** |
+| `correctionPairs` | aliases 全量可进后处理,但仅 **整词 / 高置信** 替换 |
+
+### 7.4 防污染规则
+
+- 近静音、极短音频:减少或跳过内置词,保留用户词。
+- 用户词始终优先于内置词。
+- diagnostics 必须记录「为何丢弃」某批词(超 cap、provider 不支持、场景不匹配)。
+
+### 7.5 与现有云代码复用
+
+扩展 [`PersonalDictionary+ASRBias.swift`](../OSGKeyboardShared/Models/PersonalDictionary+ASRBias.swift) 为 **单一事实来源**,新增例如:
+
+- `localPromptBias(maxCharacters:builtinTerms:)`
+- `correctionPairs()`
+- `rankedTermsForASR(limit:builtinBoost:)`
+
+避免 macOS / iOS / Cloud 三套独立拼接逻辑。
+
+---
+
+## 8. 词库策略
+
+### 8.1 PersonalDictionary
+
+| 字段 | ASR | Correction | Polish |
+|------|-----|------------|--------|
+| `term` | hotword / prompt | 标准写法 | 必须保留 |
+| `aliases` | 可进 prompt 提示 | **主战场** | 语义纠错参考 |
+| `category` | 排序权重 | — | 分组展示 |
+| `usageCount` | 排序权重 | — | — |
+
+iCloud:[`PersonalDictionaryCloudSync`](../OSGKeyboardShared/Services/PersonalDictionaryCloudSync/) 保证 Mac / iOS / Extension 一致;本地 ASR 只读 `AppGroupStore.personalDictionary`。
+
+### 8.2 phrases.tsv 分层
+
+**不全量进入 ASR prompt。**
+
+| 层级 | 用途 | 规模建议 |
+|------|------|----------|
+| L1 ASR 高价值 | `weight >= 4` 或 curated IT 品牌缩写 | 200–500 |
+| L2 场景相关 | 按 `frontApp` / 用户最近命中动态加入 | +0–100 |
+| L3 全量索引 | 后处理模糊匹配、polish 检索 | ~10k |
+
+TSV 列:`word`、`pinyin`、`source`、`weight` — 构建索引时保留 weight 用于排序。
+
+### 8.3 iOS CLM 与 macOS 关系
+
+- iOS:TSV → export script → `.bin` → `CustomLanguageModelManager`
+- macOS:TSV → `BuiltinLexiconIndex` → `LocalASRBiasAdapter` → Qwen3 / Sherpa / Polish
+- **同一 TSV 源**,两种消费格式;不尝试把 `.bin` 喂给 Sherpa/MLX
+
+---
+
+## 9. 自动词库学习(可选实验,非第一期)
+
+借鉴 Typeflux `WorkflowController+AutomaticVocabulary`:
+
+- 听写插入后,短时观察用户在前台可编辑框内的修改
+- LLM 或规则判断是否为「专名 / 品牌 / 大小写修正」
+- 候选进入 **待确认队列**,不直接写入 `PersonalDictionary`
+
+**约束(必须写进隐私说明)**:
+
+- 默认关闭
+- 不自动 iCloud 同步待确认项
+- 可一键清空、可审计来源
+- 拒绝:整句改写、纯语法修正、过短词条、编辑幅度过大
+
+OSG 已有 `PersonalDictionary.Entry.Source.recentEdit` 与合并逻辑,可与之对齐而非新建平行存储。
+
+---
+
+## 10. 本地模型管理
+
+### 10.1 原则
+
+- **Catalog 与 Runtime 分离**:下载源只影响安装;推理只读本地 **已验证 manifest**
+- **Selected model 显式**:禁止「扫描目录用第一个 onnx」
+- **完整性**:sha256 或 size 校验 + staging 目录原子发布
+
+### 10.2 存储布局(建议)
+
+```
+~/Library/Application Support/OSGKeyboard/
+ LocalASRModels/
+ manifest.json # 已安装模型、版本、backend、capabilities
+ qwen3-mlx-1.7b/ # 当前 MLX 布局(可与现路径兼容)
+ sherpa-qwen3-0.6b/
+ sherpa-sensevoice-small/
+```
+
+### 10.3 Catalog 条目(概念)
+
+```json
+{
+ "modelId": "sherpa-qwen3-asr-0.6b-int8",
+ "displayName": "Qwen3-ASR 0.6B (Sherpa)",
+ "backend": "sherpaQwen3",
+ "sizeBytes": 1200000000,
+ "recommendedLocales": ["zh-CN", "en-US"],
+ "supportsHotwords": true,
+ "hotwordMode": "recognizerScoped",
+ "sources": [
+ {
+ "type": "modelscope",
+ "url": "https://www.modelscope.cn/api/v1/models/.../repo?Revision=master&FilePath=...",
+ "sha256": "...",
+ "priority": 1
+ },
+ {
+ "type": "huggingface",
+ "url": "https://huggingface.co/...",
+ "priority": 2
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/...",
+ "priority": 3
+ }
+ ]
+}
+```
+
+### 10.4 ModelScope 策略
+
+| 场景 | 策略 |
+|------|------|
+| 中国大陆用户默认 | **ModelScope 优先**(Qwen3-ASR、SenseVoice、FunASR 相关 ONNX) |
+| 国际 / ModelScope 失败 | Hugging Face → GitHub Releases |
+| 企业内网 | `custom` mirror URL(用户配置) |
+| 安装流程 | download → verify → extract → validate required files → rename staging → update manifest |
+| 失败 | 清理 staging / 临时文件;不留下半安装状态 |
+
+MLX Qwen3 权重:可继续支持用户自选目录(现状),逐步纳入统一 catalog 的 `type: mlx` 条目。
+
+### 10.5 UI / 设置需求(规划)
+
+- 模型列表:体积、语言、安装状态、是否支持热词
+- 下载进度:phase(downloading / extracting / validating / finalizing)
+- 切换模型:仅允许 **installed + manifest 合法** 的项为默认
+- 诊断:当前 provider、capability、上次 bias diagnostics
+
+---
+
+## 11. 后端对比与决策矩阵
+
+### 11.1 产品分层
+
+```text
+主线: Qwen3 MLX + LocalASRBiasAdapter + Polish/Correction
+重点 POC: Sherpa Qwen3 hotwords
+对照: SenseVoice(速度)、Apple Speech(fallback)
+参考上限: Cloud ASR + PersonalDictionary
+团队部署: Qwen3-ASR vLLM(SayIt 式,非客户端主线)
+暂不主推: FunASR Paraformer hotwords、纯 OpenLess 本地词典叙事
+```
+
+### 11.2 详细对比
+
+| 维度 | Qwen3 MLX | Sherpa Qwen3 | SenseVoice | Apple Speech | Cloud |
+|------|-----------|--------------|------------|--------------|-------|
+| 离线 | ✅ | ✅ | ✅ | ✅ | ❌ |
+| 中英混合技术词 | 强(经验性) | 待 POC | 中 | 中 | 强 |
+| Hard hotwords | ❌→prompt | ✅ recognizerScoped | ❌ | ❌ | ✅ 因 provider 异 |
+| 实现成本 | 低(已有) | 高(runtime 体积) | 中 | 低 | 已有 |
+| 模型体积 | ~1.3GB+ | 类似 | ~350MB 级 | 0 | N/A |
+| 隐私 | 本地 | 本地 | 本地 | 本地 | 依配置 |
+
+### 11.3 Sherpa POC 通过阈值(建议)
+
+相对 **当前 Qwen3 MLX + 仅 polish** 基线:
+
+| 指标 | 建议阈值 |
+|------|----------|
+| 用户热词召回率 | 提升 ≥ 20% |
+| 误触发率(未说热词却被改成热词) | ≤ 2% |
+| 30s 音频端到端延迟 | ≤ 基线 × 1.5 |
+| 内存峰值(8GB Mac 目标机) | 可接受且无 OOM |
+| 安装成功率 | 普通用户可完成 ModelScope/HF 下载 |
+
+未达阈值:**保留 Qwen3 MLX 主线**,Sherpa 仅作高级选项。
+
+---
+
+## 12. POC 评测计划
+
+### 12.1 测试集
+
+| 类别 | 内容 | 目的 |
+|------|------|------|
+| A 普通中文 | 日常口语 50 句 | 基线 WER / 误触发 |
+| B 技术术语 | SwiftUI、Cursor、Qwen3-ASR 等 50 句 | 专名召回 |
+| C 用户词典 | 模拟 PersonalDictionary 20 词 × 多句 | 热词核心场景 |
+| D 长句润色 | 30s+ 口语 | polish 兜底 |
+| E 噪声 / 短句 | 低 SNR、<2s | 防污染规则 |
+
+### 12.2 对照矩阵
+
+| 配置 | 说明 |
+|------|------|
+| Baseline | Qwen3 MLX,无 bias |
+| B1 | Qwen3 MLX + promptBias |
+| B2 | B1 + polishFragment + correction |
+| POC1 | Sherpa Qwen3 + hardHotwords |
+| POC2 | SenseVoice,无 hotwords |
+| Ref | 云 ASR + PersonalDictionary |
+
+### 12.3 指标
+
+- Raw CER/WER(中文可用字错误率)
+- **Hotword recall**(用户词是否出现在 raw 或 final)
+- **False hotword rate**
+- Final accuracy(用户主观或编辑距离)
+- Latency:record end → text inserted
+- Memory / CPU、模型加载时间
+- 离线可靠性(无网络完成全流程)
+
+---
+
+## 13. 失败回退策略
+
+```mermaid
+flowchart TD
+ start["recognize 开始"] --> qwen{"Qwen3 MLX 可用?"}
+ qwen -->|是| qwenRun["Qwen3 + bias"]
+ qwen -->|否| apple["Apple Speech"]
+ qwenRun -->|失败| apple
+ qwenRun -->|成功| post["Correction + Polish"]
+ apple --> post
+ post -->|Polish 失败| raw["返回 raw transcript"]
+ post -->|成功| done["插入 final"]
+```
+
+| 条件 | 行为 |
+|------|------|
+| Qwen3 权重缺失 | Apple Speech(现状) |
+| Qwen3 推理失败 | 可配置:重试一次 → Apple Speech |
+| Sherpa 未安装 | 不回退云;提示下载 |
+| 模型 manifest 损坏 | 标记 invalid,禁止设为默认 |
+| Polish 失败 | 使用 raw(现状) |
+| 用户禁用云 | 不静默切云 |
+
+---
+
+## 14. 分阶段落地路线
+
+| Phase | 内容 | 交付物 |
+|-------|------|--------|
+| **1** | 本文档定稿;`LocalASRCapabilities` + `LocalASRBiasPayload` 类型设计 | 架构文档 + ADR 可选 |
+| **2** | `MacDictationPipeline` 接入 adapter;Qwen3 MLX `promptBias`;polish + correction | 实现 PR |
+| **3** | `BuiltinLexiconIndex`;Top-N;diagnostics UI | 实现 PR |
+| **4** | Model catalog + ModelScope 下载 + manifest | 实现 PR |
+| **5** | Sherpa Qwen3 POC + 评测报告 | 决策是否默认切换 |
+| **6** | 可选:自动词库学习实验(默认关) | 功能 flag |
+
+---
+
+## 15. 风险与待确认问题
+
+| 风险 | 缓解 |
+|------|------|
+| Qwen3 prompt bias 过弱 | POC 对比 Sherpa hard hotwords;保留 correction + polish |
+| 热词过多污染识别 | cap + 场景筛选 + diagnostics |
+| aliases 后处理误改 | 整词边界、低置信跳过 |
+| Sherpa 分发体积 / 签名 / 公证 | 单独评估;可选按需下载 |
+| Apple Speech macOS CLM | 调研 macOS 26+ 是否可接 CLM;否则仅 fallback |
+| ModelScope API 变更 | 多 mirror;manifest 可更新 URL |
+| 自动学习隐私 | 默认关、本地、待确认 |
+| 低配 Mac 内存 | 单模型常驻策略;SenseVoice 作轻量选项 |
+
+**待确认**:
+
+1. MLX Swift API 是否暴露等价 `setPrompt`(对标 Open-Less/qwen-asr `qwen_set_prompt`)
+2. Sherpa-ONNX Swift/SPM 与 App Store 公证路径
+3. `phrases.tsv` Top-N 是否按 `weight` 静态裁剪即可,或需按 App 动态检索
+
+---
+
+## 16. 相关代码索引
+
+| 主题 | 路径 |
+|------|------|
+| Mac 听写管道 | `OSGKeyboardMac/MacDictationPipeline.swift` |
+| 本地 ASR 入口 | `OSGKeyboardMac/MacLocalASRService.swift` |
+| Qwen3 MLX | `OSGKeyboardMac/MacQwen3LocalASR.swift`, `MacQwen3ASREngine.swift` |
+| Apple Speech fallback | `OSGKeyboardMac/MacSpeechLocalASR.swift` |
+| 用户词库 | `OSGKeyboardShared/Models/PersonalDictionary.swift` |
+| 云 bias | `OSGKeyboardShared/Models/PersonalDictionary+ASRBias.swift` |
+| 内置 TSV | `OSGKeyboard/Resources/CustomLanguageModel/v1/phrases.tsv` |
+| iOS CLM | `OSGKeyboardShared/Services/CustomLanguageModelManager.swift` |
+| 润色 | `OSGKeyboardShared/Services/PolishingService.swift` |
+| 文本插入 | `OSGKeyboardMac/MacTextInsertionService.swift` |
+
+---
+
+## 17. 修订记录
+
+| 日期 | 说明 |
+|------|------|
+| 2026-03-31 | 初版:基于 OSG 代码审计 + typeless-alternative 竞品源码 + 计划评审(ModelScope、hotwordMode、回退策略) |
diff --git a/project.yml b/project.yml
index d2b7d39..70b8ead 100644
--- a/project.yml
+++ b/project.yml
@@ -50,8 +50,8 @@ settings:
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS: YES
STRING_CATALOG_GENERATE_SYMBOLS: YES
CLANG_CXX_LANGUAGE_STANDARD: c++17
- MARKETING_VERSION: "0.5.0"
- CURRENT_PROJECT_VERSION: "18"
+ MARKETING_VERSION: "0.5.2"
+ CURRENT_PROJECT_VERSION: "19"
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
# 项目级签名 xcconfig,适用于所有 target
@@ -327,6 +327,7 @@ targets:
resources:
- path: OSGKeyboardShared/Resources/CustomLanguageModel/v1/OSGKeyboardCLM.bin
- path: OSGKeyboardShared/Resources/CustomLanguageModel/v1/compiled-manifest.json
+ - path: OSGKeyboardShared/Resources/LocalASR/local-asr-catalog.json
info:
path: OSGKeyboardShared/Info.plist
settings:
@@ -429,10 +430,18 @@ targets:
buildPhase: resources
- path: OSGKeyboardShared/zh-Hans.lproj/Shared.strings
buildPhase: resources
+ - path: OSGKeyboard/Resources/CustomLanguageModel/v1/phrases.tsv
+ buildPhase: resources
+ - path: OSGKeyboardShared/Resources/LocalASR/local-asr-catalog.json
+ buildPhase: resources
entitlements:
path: OSGKeyboardMac/OSGKeyboardMac.entitlements
properties:
- com.apple.security.app-sandbox: true
+ # Sandbox disabled on purpose: this menu-bar dictation utility uses the
+ # Accessibility API to monitor a global hotkey and inject ⌘V into other
+ # apps — both are forbidden by App Sandbox. Ship via Developer ID +
+ # notarization (not Mac App Store / TestFlight, which force the sandbox).
+ com.apple.security.app-sandbox: false
com.apple.security.device.audio-input: true
com.apple.security.network.client: true
com.apple.developer.ubiquity-kvstore-identifier: $(TeamIdentifierPrefix)com.osgkeyboard.ios
@@ -455,6 +464,9 @@ targets:
ITSAppUsesNonExemptEncryption: false
settings:
base:
+ PRODUCT_NAME: OSGKeyboard
+ INFOPLIST_KEY_CFBundleDisplayName: OSGKeyboard
+ INFOPLIST_KEY_CFBundleName: OSGKeyboard
PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.mac
MACOSX_DEPLOYMENT_TARGET: "15.0"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
@@ -462,6 +474,8 @@ targets:
APPLICATION_EXTENSION_API_ONLY: NO
CODE_SIGN_STYLE: Automatic
DEVELOPMENT_TEAM: X329MZU23S
+ # Required for Developer ID distribution + notarization (outside App Store).
+ ENABLE_HARDENED_RUNTIME: YES
dependencies:
- sdk: Speech.framework
- sdk: AVFoundation.framework
From dcb66a9849bcb405859c1a7b70b8700431f6a759 Mon Sep 17 00:00:00 2001
From: Rocky <72559939+hkgood@users.noreply.github.com>
Date: Thu, 9 Jul 2026 17:13:07 +0800
Subject: [PATCH 3/3] feat: sync force-quit Flow teardown and polish macOS
local ASR UX
End Live Activities and release the audio session synchronously on
applicationWillTerminate, and continue macOS local-model install,
onboarding, and settings polish on this branch.
---
.gitignore | 3 +
CHANGELOG.md | 3 +
OSGKeyboard/Services/AppURLHandler.swift | 8 +
.../Services/FlowLiveActivityController.swift | 17 +
OSGKeyboard/Services/FlowSessionManager.swift | 68 ++
.../Services/FlowTerminationCoordinator.swift | 24 +
OSGKeyboardMac/DashboardView.swift | 76 +-
OSGKeyboardMac/MacComponents.swift | 27 +-
OSGKeyboardMac/MacContentView.swift | 11 +
OSGKeyboardMac/MacDictationViewModel.swift | 28 +-
OSGKeyboardMac/MacDictionaryView.swift | 11 +-
OSGKeyboardMac/MacHistoryView.swift | 10 +-
OSGKeyboardMac/MacLegalSettingsViews.swift | 110 +++
OSGKeyboardMac/MacLegalWebView.swift | 48 ++
.../MacLocalASRModelSettingsView.swift | 95 +--
OSGKeyboardMac/MacLocalASRService.swift | 80 +-
OSGKeyboardMac/MacOnboardingView.swift | 699 ++++++++++++++++++
OSGKeyboardMac/MacQwen3LocalASR.swift | 17 +-
OSGKeyboardMac/MacRootView.swift | 70 +-
OSGKeyboardMac/MacSettingsView.swift | 70 +-
OSGKeyboardMac/MacSherpaLocalASR.swift | 8 +
OSGKeyboardMac/MacSherpaONNXRunner.swift | 27 +
OSGKeyboardMac/MacTheme.swift | 117 ++-
OSGKeyboardMac/OSGKeyboardMacApp.swift | 43 +-
.../Models/LocalASRCapabilities.swift | 9 +
.../Models/LocalASRModelCatalog.swift | 61 ++
.../Resources/LocalASR/local-asr-catalog.json | 93 ++-
.../Services/LocalASRModelInstallState.swift | 9 +-
.../Services/LocalASRModelManager.swift | 262 +++++--
OSGKeyboardShared/en.lproj/Shared.strings | 47 +-
.../zh-Hans.lproj/Shared.strings | 47 +-
.../LocalASRDownloadSourceSorterTests.swift | 47 ++
.../LocalASRModelCatalogTests.swift | 28 +-
project.yml | 3 +
34 files changed, 1936 insertions(+), 340 deletions(-)
create mode 100644 OSGKeyboard/Services/FlowTerminationCoordinator.swift
create mode 100644 OSGKeyboardMac/MacLegalSettingsViews.swift
create mode 100644 OSGKeyboardMac/MacLegalWebView.swift
create mode 100644 OSGKeyboardMac/MacOnboardingView.swift
create mode 100644 OSGKeyboardTests/LocalASRDownloadSourceSorterTests.swift
diff --git a/.gitignore b/.gitignore
index 5b038ea..e381900 100644
--- a/.gitignore
+++ b/.gitignore
@@ -70,3 +70,6 @@ __pycache__/
.flow-oslog-capture.log
.flow-*.log
.osg_*.png
+
+# Standalone experiment: 灵动岛录音机制验证 demo (throwaway)
+AudioIntentProbe/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 637796a..2f87910 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,9 @@ 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
diff --git a/OSGKeyboard/Services/AppURLHandler.swift b/OSGKeyboard/Services/AppURLHandler.swift
index 916b2a6..50c2a2a 100644
--- a/OSGKeyboard/Services/AppURLHandler.swift
+++ b/OSGKeyboard/Services/AppURLHandler.swift
@@ -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 {
diff --git a/OSGKeyboard/Services/FlowLiveActivityController.swift b/OSGKeyboard/Services/FlowLiveActivityController.swift
index a173f26..eb374d1 100644
--- a/OSGKeyboard/Services/FlowLiveActivityController.swift
+++ b/OSGKeyboard/Services/FlowLiveActivityController.swift
@@ -116,4 +116,21 @@ enum FlowLiveActivityController {
}
}
}
+
+ /// `applicationWillTerminate` 专用:阻塞到所有 `end` 完成,避免进程先退出而锁屏卡片残留。
+ nonisolated static func endAllSynchronouslyOnTerminate() {
+ let semaphore = DispatchSemaphore(value: 0)
+ Task.detached(priority: .userInitiated) {
+ let activities = Activity.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
+ }
}
diff --git a/OSGKeyboard/Services/FlowSessionManager.swift b/OSGKeyboard/Services/FlowSessionManager.swift
index cc7539d..b9ad9d1 100644
--- a/OSGKeyboard/Services/FlowSessionManager.swift
+++ b/OSGKeyboard/Services/FlowSessionManager.swift
@@ -88,6 +88,7 @@ final class FlowSessionManager: ObservableObject {
capture.onEngineLiveChanged = { [weak self] _ in
self?.refreshHostReady()
}
+ FlowTerminationCoordinator.register(self)
}
// MARK: - Public
@@ -210,6 +211,73 @@ final class FlowSessionManager: ObservableObject {
AppPermissions.openSystemSettings()
}
+ /// 强杀专用同步 teardown:不等待 ASR/LLM;Live Activity 由
+ /// `FlowTerminationCoordinator` 同步 `end`。
+ func prepareForProcessTermination() {
+ debug("prepareForProcessTermination")
+
+ coldStartContext = nil
+ isColdStartHandoff = false
+ coldStartRecoveryTask?.cancel()
+ coldStartRecoveryTask = nil
+ startTask?.cancel()
+ startTask = nil
+ commandObserver = nil
+ pollingTask?.cancel()
+ pollingTask = nil
+ heartbeatTask?.cancel()
+ heartbeatTask = nil
+ expiryTask?.cancel()
+ expiryTask = nil
+ levelTask?.cancel()
+ levelTask = nil
+ finalizeTask?.cancel()
+ finalizeTask = nil
+ utteranceSafetyTask?.cancel()
+ utteranceSafetyTask = nil
+ asrTask?.cancel()
+ asrTask = nil
+ chunkedPipeline = nil
+
+ if isUtteranceRecording || isUtteranceProcessing {
+ capture.cancelUtterance()
+ asr.cancel()
+ }
+
+ capture.cancelUtterance()
+ if capture.running {
+ capture.stop()
+ }
+
+ endBackgroundKeepAlive()
+ ScreenWakeLock.release()
+
+ sessionASR?.cancel()
+ sessionASR = nil
+ sessionASREngineMode = nil
+ sessionASRWarmedLocaleID = nil
+
+ if isActive || FlowSessionBridge.isSessionActive() {
+ FlowSessionBridge.markSessionInactive()
+ FlowSessionDarwin.postSessionChanged()
+ }
+
+ activeSessionId = nil
+ currentUtteranceId = nil
+ currentCommandSeq = 0
+ lastHandledCommandSeq = 0
+ isUtteranceRecording = false
+ isUtteranceProcessing = false
+ isActive = false
+ isStarting = false
+ sessionExpiresAt = nil
+ sessionWarning = nil
+ currentPartial = ""
+ lastFinal = ""
+ chunkWarnings = []
+ FlowSessionBridge.setHostReady(false)
+ }
+
func endSession() {
guard isActive else { return }
debug("Flow session ended")
diff --git a/OSGKeyboard/Services/FlowTerminationCoordinator.swift b/OSGKeyboard/Services/FlowTerminationCoordinator.swift
new file mode 100644
index 0000000..ec0a3bc
--- /dev/null
+++ b/OSGKeyboard/Services/FlowTerminationCoordinator.swift
@@ -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()
+ }
+}
diff --git a/OSGKeyboardMac/DashboardView.swift b/OSGKeyboardMac/DashboardView.swift
index ab2f4b1..e06343a 100644
--- a/OSGKeyboardMac/DashboardView.swift
+++ b/OSGKeyboardMac/DashboardView.swift
@@ -34,11 +34,15 @@ struct DashboardView: View {
Text(MacL10n.format("mac.foregroundApp", language: lang, appName))
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
+ .transition(.opacity)
}
statGrid
dictationCanvas
}
- .padding(Spacing.lg)
+ .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)
@@ -91,23 +95,30 @@ struct DashboardView: View {
private var dictationCanvas: some View {
MacCard(padding: Spacing.lg) {
- 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)
- .frame(maxWidth: .infinity, minHeight: 220, alignment: .topLeading)
- } else {
- Text(viewModel.transcript)
- .font(.system(size: 22, weight: .regular))
- .foregroundStyle(palette.textPrimary)
- .textSelection(.enabled)
+ 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)
}
}
@@ -132,12 +143,11 @@ struct BottomDictationBar: View {
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
- .macGlassSurface(in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous), fillOpacity: 0.78)
+ .macGlassSurface(in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous), fillOpacity: 1)
.overlay(
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.dividerStrong, lineWidth: 0.5)
)
- .shadow(color: palette.textPrimary.opacity(0.12), radius: 18, y: 8)
}
private var readinessChip: some View {
@@ -152,10 +162,12 @@ struct BottomDictationBar: View {
)
.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 {
@@ -195,8 +207,10 @@ struct BottomDictationBar: View {
.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 {
@@ -205,25 +219,31 @@ struct BottomDictationBar: View {
Circle()
.fill(viewModel.isRecording ? palette.recordRed : palette.accent)
.frame(width: 52, height: 52)
- .macGlassSurface(in: Circle(), fillOpacity: 0.2)
.shadow(
- color: (viewModel.isRecording ? palette.recordRed : palette.accent).opacity(0.5),
- radius: pulse ? 14 : 6
+ color: (viewModel.isRecording ? palette.recordRed : palette.accent).opacity(0.35),
+ radius: pulse ? 10 : 5
)
- 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)
+ 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(.easeInOut(duration: 1.1).repeatForever(autoreverses: true)) {
+ withAnimation(Motion.breath) {
pulse = true
}
}
diff --git a/OSGKeyboardMac/MacComponents.swift b/OSGKeyboardMac/MacComponents.swift
index abd509b..8daa33f 100644
--- a/OSGKeyboardMac/MacComponents.swift
+++ b/OSGKeyboardMac/MacComponents.swift
@@ -38,20 +38,17 @@ private struct MacGlassSurface: ViewModifier {
let fillOpacity: Double
func body(content: Content) -> some View {
- if #available(macOS 26.0, *) {
- content
- .background(palette.surface.opacity(fillOpacity), in: shape)
- .glassEffect(.regular, in: shape)
- } else {
- content
- .background(palette.surface.opacity(fillOpacity), in: shape)
- }
+ // 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 Liquid Glass on macOS 26 while keeping the same semantic
- /// surface colour on older systems.
+ /// Applies a flat semantic surface fill (no drop shadow) behind `content`.
func macGlassSurface(
in shape: S,
fillOpacity: Double = 0.72
@@ -97,7 +94,7 @@ struct MacCard: View {
content()
.padding(padding)
- .macGlassSurface(in: shape)
+ .macGlassSurface(in: shape, fillOpacity: 1)
.overlay(
shape
.stroke(palette.divider, lineWidth: 0.5)
@@ -135,6 +132,8 @@ struct StatCard: View {
.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)
@@ -165,7 +164,7 @@ struct MiniWaveform: View {
}
}
.frame(height: 22)
- .animation(.easeOut(duration: 0.12), value: level)
+ .animation(Motion.instant, value: level)
.onAppear {
withAnimation(.linear(duration: 0.9).repeatForever(autoreverses: true)) {
phase = 1
@@ -214,12 +213,14 @@ struct MacStatusFooter: View {
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)
@@ -228,5 +229,7 @@ struct MacStatusFooter: View {
.labelStyle(.titleAndIcon)
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.xs)
+ .animation(Motion.quick, value: viewModel.isCloudMode)
+ .animation(Motion.quick, value: viewModel.config.translationTargetLocaleId)
}
}
diff --git a/OSGKeyboardMac/MacContentView.swift b/OSGKeyboardMac/MacContentView.swift
index ebb3c51..ed4b6f1 100644
--- a/OSGKeyboardMac/MacContentView.swift
+++ b/OSGKeyboardMac/MacContentView.swift
@@ -22,6 +22,7 @@ struct MacContentView: View {
Text(viewModel.statusMessage)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
+ .contentTransition(.opacity)
if !viewModel.transcript.isEmpty {
ScrollView {
@@ -34,6 +35,7 @@ struct MacContentView: View {
.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)
@@ -43,6 +45,8 @@ struct MacContentView: View {
}
.padding(Spacing.md)
.background(palette.background)
+ .animation(Motion.soft, value: viewModel.transcript.isEmpty)
+ .animation(Motion.quick, value: viewModel.statusMessage)
}
private var statusRow: some View {
@@ -103,20 +107,24 @@ struct MacContentView: View {
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(
@@ -127,6 +135,9 @@ struct MacContentView: View {
}
.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 {
diff --git a/OSGKeyboardMac/MacDictationViewModel.swift b/OSGKeyboardMac/MacDictationViewModel.swift
index 09f3786..ad44d3e 100644
--- a/OSGKeyboardMac/MacDictationViewModel.swift
+++ b/OSGKeyboardMac/MacDictationViewModel.swift
@@ -101,25 +101,11 @@ final class MacDictationViewModel: ObservableObject {
func onAppear() async {
await MacICloudSyncBootstrap.pullIfEnabled()
refreshForegroundAppName()
- warmUpQwen3IfNeeded()
- }
-
- /// Pre-load MLX weights + Metal shaders so the first dictation is fast.
- func warmUpQwen3IfNeeded() {
- guard config.engineMode == "local",
- let model = MacLocalASRService.selectedModelDefinition(),
- model.backend == .mlx,
- MacLocalASRService.isModelInstalled(model) else { return }
- let path = MacLocalASRPreferences.qwen3ModelPath
- Task.detached(priority: .utility) {
- _ = try? await MacQwen3ASREngine.shared.prepareIfNeeded(modelPath: path)
- }
}
func reloadConfigFromCloud() {
config.reloadFromPersistedStorage()
statusMessage = MacL10n.string("mac.status.ready", language: config.uiLanguage)
- warmUpQwen3IfNeeded()
}
func refreshDictionaryFromCloud() {
@@ -159,10 +145,8 @@ final class MacDictationViewModel: ObservableObject {
var localModelReady: Bool {
_ = localModelRevision
- if let model = MacLocalASRService.selectedModelDefinition() {
- return MacLocalASRService.isModelInstalled(model)
- }
- return MacLocalASRPreferences.qwen3ModelIsInstalled()
+ 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.
@@ -173,9 +157,6 @@ final class MacDictationViewModel: ObservableObject {
guard let model = MacLocalASRService.selectedModelDefinition() else {
return MacL10n.string("mac.settings.localModelFallbackApple", language: config.uiLanguage)
}
- if model.installKind == .manual {
- return MacL10n.string("mac.settings.mlxModelMissing", language: config.uiLanguage)
- }
return MacL10n.format(
"mac.settings.selectedModelMissing",
language: config.uiLanguage,
@@ -190,10 +171,6 @@ final class MacDictationViewModel: ObservableObject {
objectWillChange.send()
}
- var qwen3ModelInstalled: Bool {
- MacLocalASRPreferences.qwen3ModelIsInstalled()
- }
-
// MARK: - Preferences
func setAutoPasteEnabled(_ enabled: Bool) {
@@ -210,7 +187,6 @@ final class MacDictationViewModel: ObservableObject {
func setEngineMode(_ mode: String) {
config.engineMode = mode
- if mode == "local" { warmUpQwen3IfNeeded() }
}
// MARK: - Recording
diff --git a/OSGKeyboardMac/MacDictionaryView.swift b/OSGKeyboardMac/MacDictionaryView.swift
index f2f986b..1a3c80d 100644
--- a/OSGKeyboardMac/MacDictionaryView.swift
+++ b/OSGKeyboardMac/MacDictionaryView.swift
@@ -48,12 +48,15 @@ struct MacDictionaryView: 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()
@@ -86,6 +89,7 @@ struct MacDictionaryView: View {
.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),
@@ -93,7 +97,9 @@ struct MacDictionaryView: View {
titleVisibility: .visible
) {
Button(MacL10n.string("mac.delete", language: lang), role: .destructive) {
- if let entry = entryPendingDeletion { delete(entry) }
+ if let entry = entryPendingDeletion {
+ withAnimation(Motion.soft) { delete(entry) }
+ }
entryPendingDeletion = nil
}
Button(MacL10n.string("mac.cancel", language: lang), role: .cancel) {
@@ -215,12 +221,13 @@ private struct MacDictionaryRow: View {
.frame(width: 24, height: 24)
}
.buttonStyle(.borderless)
- .foregroundStyle(palette.textTertiary)
+ .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) {
diff --git a/OSGKeyboardMac/MacHistoryView.swift b/OSGKeyboardMac/MacHistoryView.swift
index f9edc2d..e02f914 100644
--- a/OSGKeyboardMac/MacHistoryView.swift
+++ b/OSGKeyboardMac/MacHistoryView.swift
@@ -34,12 +34,15 @@ struct MacHistoryView: 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
@@ -64,7 +67,7 @@ struct MacHistoryView: View {
titleVisibility: .visible
) {
Button(MacL10n.string("mac.history.clearConfirm", language: lang), role: .destructive) {
- historyStore.clearAll()
+ withAnimation(Motion.soft) { historyStore.clearAll() }
}
Button(MacL10n.string("mac.cancel", language: lang), role: .cancel) {}
} message: {
@@ -95,7 +98,7 @@ struct MacHistoryView: View {
time: Self.timeFormatter.string(from: entry.createdAt),
language: lang,
copy: { viewModel.copyToClipboard(entry.text) },
- delete: { historyStore.delete(id: entry.id) }
+ delete: { withAnimation(Motion.soft) { historyStore.delete(id: entry.id) } }
)
}
@@ -144,12 +147,13 @@ private struct MacHistoryRow: View {
.frame(width: 24, height: 24)
}
.buttonStyle(.borderless)
- .foregroundStyle(palette.textTertiary)
+ .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) {
diff --git a/OSGKeyboardMac/MacLegalSettingsViews.swift b/OSGKeyboardMac/MacLegalSettingsViews.swift
new file mode 100644
index 0000000..004304c
--- /dev/null
+++ b/OSGKeyboardMac/MacLegalSettingsViews.swift
@@ -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)
+ }
+}
diff --git a/OSGKeyboardMac/MacLegalWebView.swift b/OSGKeyboardMac/MacLegalWebView.swift
new file mode 100644
index 0000000..287730d
--- /dev/null
+++ b/OSGKeyboardMac/MacLegalWebView.swift
@@ -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 }
+ }
+ }
+}
diff --git a/OSGKeyboardMac/MacLocalASRModelSettingsView.swift b/OSGKeyboardMac/MacLocalASRModelSettingsView.swift
index fc10bfd..dee9a3c 100644
--- a/OSGKeyboardMac/MacLocalASRModelSettingsView.swift
+++ b/OSGKeyboardMac/MacLocalASRModelSettingsView.swift
@@ -1,7 +1,7 @@
// MacLocalASRModelSettingsView.swift
// OSGKeyboard · Mac
//
-// Local ASR model catalog, download progress, MLX path, and bias diagnostics.
+// Local ASR model catalog, download progress, and bias diagnostics.
import AppKit
import SwiftUI
@@ -29,9 +29,11 @@ final class MacLocalASRModelSettingsViewModel: ObservableObject {
catalog = try? LocalASRModelCatalog.loadBundled()
if let catalog {
let manifest = LocalASRInstalledManifestIO.load(defaultModelId: catalog.defaultModelId)
- selectedModelId = manifest.selectedModelId.isEmpty
- ? MacLocalASRPreferences.selectedModelId
- : manifest.selectedModelId
+ selectedModelId = MacLocalASRPreferences.migratedModelId(
+ manifest.selectedModelId.isEmpty
+ ? MacLocalASRPreferences.selectedModelId
+ : manifest.selectedModelId
+ )
}
diagnosticsSnapshot = LocalASRBiasDiagnosticsStore.load()
onLocalModelStateChanged?()
@@ -46,7 +48,7 @@ final class MacLocalASRModelSettingsViewModel: ObservableObject {
}
func installedDiskUsage(_ model: LocalASRModelDefinition) -> String? {
- guard model.installKind == .archive,
+ guard model.installKind == .archive || model.installKind == .repository,
let relative = model.installRelativePath,
isInstalled(model) else { return nil }
let dir = LocalASRModelInstallState.installDirectory(for: relative)
@@ -140,15 +142,6 @@ final class MacLocalASRModelSettingsViewModel: ObservableObject {
NSWorkspace.shared.activateFileViewerSelecting([url])
}
- /// Opens (creating if needed) the model's shared subfolder so the user can
- /// drop in manually-converted weights (used by the MLX model).
- func revealModelFolder(_ model: LocalASRModelDefinition) {
- guard let relative = model.installRelativePath else { return }
- let url = LocalASRModelInstallState.installDirectory(for: relative)
- try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
- NSWorkspace.shared.open(url)
- }
-
func revealStorageRoot() {
let url = LocalASRModelInstallState.rootDirectory()
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
@@ -212,7 +205,6 @@ struct MacLocalASRModelSettingsView: View {
Group {
if let catalog = modelVM.catalog {
modelPickerSection(catalog: catalog)
- runtimeSection(catalog: catalog)
} else {
Text(MacL10n.string("mac.localASR.catalogMissing", language: lang))
.foregroundStyle(palette.textSecondary)
@@ -226,6 +218,16 @@ struct MacLocalASRModelSettingsView: View {
private func modelPickerSection(catalog: LocalASRCatalogDocument) -> some View {
Section {
+ if let runtime = modelVM.currentRuntime(in: catalog) {
+ LabeledContent(runtime.displayName) {
+ Text(
+ modelVM.isRuntimeInstalled(runtime)
+ ? MacL10n.string("mac.localASR.installed", language: lang)
+ : MacL10n.string("mac.localASR.notInstalled", language: lang)
+ )
+ }
+ }
+
ForEach(catalog.models) { model in
modelRow(model)
}
@@ -251,31 +253,6 @@ struct MacLocalASRModelSettingsView: View {
}
} header: {
Text(MacL10n.string("mac.localASR.models", language: lang))
- } footer: {
- Text(MacL10n.string("mac.localASR.modelsDesc", language: lang))
- .font(TypeStyle.caption)
- .foregroundStyle(palette.textSecondary)
- }
- }
-
- private func runtimeSection(catalog: LocalASRCatalogDocument) -> some View {
- Group {
- if let runtime = modelVM.currentRuntime(in: catalog) {
- Section {
- LabeledContent(runtime.displayName) {
- Text(
- modelVM.isRuntimeInstalled(runtime)
- ? MacL10n.string("mac.localASR.installed", language: lang)
- : MacL10n.string("mac.localASR.notInstalled", language: lang)
- )
- }
- Text(MacL10n.string("mac.localASR.runtimeDesc", language: lang))
- .font(TypeStyle.caption)
- .foregroundStyle(palette.textSecondary)
- } header: {
- Text(MacL10n.string("mac.localASR.runtime", language: lang))
- }
- }
}
}
@@ -293,9 +270,22 @@ struct MacLocalASRModelSettingsView: View {
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) {
- Text(model.displayName)
- .foregroundStyle(palette.textPrimary)
+ 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)
@@ -308,6 +298,8 @@ struct MacLocalASRModelSettingsView: View {
Spacer()
modelRowActions(model: model, installed: installed, installing: installing)
+ .animation(Motion.soft, value: installing)
+ .animation(Motion.soft, value: installed)
}
}
.padding(.vertical, 2)
@@ -344,18 +336,13 @@ struct MacLocalASRModelSettingsView: View {
)
}
}
- } else if model.installKind == .manual {
- Button(MacL10n.string("mac.localASR.openFolder", language: lang)) {
- modelVM.revealModelFolder(model)
- }
- .buttonStyle(.bordered)
- .controlSize(.small)
} 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)
@@ -382,7 +369,7 @@ struct MacLocalASRModelSettingsView: View {
.trim(from: 0, to: fraction)
.stroke(palette.accent, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.rotationEffect(.degrees(-90))
- .animation(.linear(duration: 0.15), value: fraction)
+ .animation(Motion.instant, value: fraction)
if modelVM.installProgress.phase == .paused {
Image(systemName: "pause.fill")
.font(.system(size: 10, weight: .bold))
@@ -399,16 +386,10 @@ struct MacLocalASRModelSettingsView: View {
private func modelSubtitle(_ model: LocalASRModelDefinition, installed: Bool) -> String {
let size = modelVM.formattedSize(model.sizeBytes)
- let hotword = model.supportsHotwords
- ? MacL10n.string("mac.localASR.hotwordsYes", language: lang)
- : MacL10n.string("mac.localASR.hotwordsNo", language: lang)
- let state = installed
- ? MacL10n.string("mac.localASR.installed", language: lang)
- : MacL10n.string("mac.localASR.notInstalled", language: lang)
if let usage = modelVM.installedDiskUsage(model) {
- return "\(size) · \(hotword) · \(state) · \(usage)"
+ return "\(size) · \(usage)"
}
- return "\(size) · \(hotword) · \(state)"
+ return size
}
private var diagnosticsSection: some View {
diff --git a/OSGKeyboardMac/MacLocalASRService.swift b/OSGKeyboardMac/MacLocalASRService.swift
index 6d185fc..71154fc 100644
--- a/OSGKeyboardMac/MacLocalASRService.swift
+++ b/OSGKeyboardMac/MacLocalASRService.swift
@@ -2,13 +2,13 @@
// OSGKeyboard · Mac
//
// On-device ASR for macOS. Routes through the bundled local ASR catalog:
-// Qwen3 MLX (default), Sherpa Qwen3 hotwords POC, SenseVoice, Apple Speech fallback.
+// Sherpa Qwen3 (default), Paraformer, SenseVoice, Apple Speech fallback.
import Foundation
enum MacLocalASRBackend: String, Sendable, CaseIterable {
- case qwen3MLX
case sherpaQwen3
+ case sherpaParaformer
case sherpaSenseVoice
case appleSpeech
}
@@ -42,48 +42,37 @@ enum MacLocalASRError: Error, LocalizedError {
enum MacLocalASRPreferences {
static let backendKey = "mac.localASR.backend"
static let selectedModelIdKey = LocalASRPreferenceKeys.selectedModelId
- /// Shared managed subfolder for the manually-provided MLX weights.
+ /// 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 raw
+ return migratedModelId(raw)
}
- return legacyBackend == .appleSpeech ? "apple-speech-fallback" : "qwen3-mlx-1.7b"
+ 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 .qwen3MLX
+ return .sherpaQwen3
}
+ if raw == "qwen3MLX" { return .sherpaQwen3 }
return value
}
-
- /// Fixed location inside the shared managed model storage root. All three
- /// catalog models live under the same directory, so MLX no longer needs a
- /// per-model folder picker — the user drops converted weights here.
- static var qwen3ModelPath: String {
- LocalASRModelInstallState.installDirectory(for: qwen3ModelRelativePath).path
- }
-
- static func qwen3ModelIsInstalled(at path: String = qwen3ModelPath) -> 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")
- }
}
enum MacLocalASRService {
@@ -97,7 +86,7 @@ enum MacLocalASRService {
let manifest = LocalASRInstalledManifestIO.load(defaultModelId: catalog.defaultModelId)
let selectedId = manifest.selectedModelId.isEmpty
? MacLocalASRPreferences.selectedModelId
- : manifest.selectedModelId
+ : MacLocalASRPreferences.migratedModelId(manifest.selectedModelId)
if selectedId == "apple-speech-fallback" { return nil }
return LocalASRModelCatalog.model(selectedId, in: catalog)
?? LocalASRModelCatalog.model(catalog.defaultModelId, in: catalog)
@@ -116,34 +105,19 @@ enum MacLocalASRService {
static func isModelInstalled(_ model: LocalASRModelDefinition) -> Bool {
LocalASRModelInstallState.isInstalled(
model,
- manualMLXPath: MacLocalASRPreferences.qwen3ModelPath
+ manualMLXPath: nil,
+ fileManager: FileManager.default
)
}
- /// Transcribe using the selected catalog model, with MLX → Apple Speech fallback.
+ /// 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) {
- do {
- return try await transcribeWithModel(model, samples: samples, locale: locale, bias: bias)
- } catch {
- if model.backend != .mlx {
- throw error
- }
- }
- }
-
- if MacLocalASRPreferences.qwen3ModelIsInstalled() {
- return try await MacQwen3LocalASR.transcribe(
- samples: samples,
- sampleRate: 16_000,
- locale: locale,
- modelPath: MacLocalASRPreferences.qwen3ModelPath,
- bias: bias
- )
+ return try await transcribeWithModel(model, samples: samples, locale: locale, bias: bias)
}
return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale)
@@ -157,14 +131,8 @@ enum MacLocalASRService {
) async throws -> String {
switch model.backend {
case .mlx:
- return try await MacQwen3LocalASR.transcribe(
- samples: samples,
- sampleRate: 16_000,
- locale: locale,
- modelPath: MacLocalASRPreferences.qwen3ModelPath,
- bias: bias
- )
- case .sherpaQwen3, .sherpaSenseVoice:
+ throw MacLocalASRError.qwen3ModelMissing
+ case .sherpaQwen3, .sherpaSenseVoice, .sherpaParaformer:
return try await MacSherpaLocalASR.transcribe(
samples: samples,
sampleRate: 16_000,
diff --git a/OSGKeyboardMac/MacOnboardingView.swift b/OSGKeyboardMac/MacOnboardingView.swift
new file mode 100644
index 0000000..caacf77
--- /dev/null
+++ b/OSGKeyboardMac/MacOnboardingView.swift
@@ -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?
+
+ 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 {
+ 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")
+ }
+ }
+}
diff --git a/OSGKeyboardMac/MacQwen3LocalASR.swift b/OSGKeyboardMac/MacQwen3LocalASR.swift
index cd46822..4088df3 100644
--- a/OSGKeyboardMac/MacQwen3LocalASR.swift
+++ b/OSGKeyboardMac/MacQwen3LocalASR.swift
@@ -15,7 +15,7 @@ enum MacQwen3LocalASR {
modelPath: String,
bias: LocalASRBiasPayload? = nil
) async throws -> String {
- guard MacLocalASRPreferences.qwen3ModelIsInstalled(at: modelPath) else {
+ guard modelDirectoryIsInstalled(at: modelPath) else {
throw MacLocalASRError.qwen3ModelMissing
}
guard sampleRate == 16_000 else {
@@ -40,4 +40,19 @@ enum MacQwen3LocalASR {
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")
+ }
}
diff --git a/OSGKeyboardMac/MacRootView.swift b/OSGKeyboardMac/MacRootView.swift
index d4552d9..a959dfb 100644
--- a/OSGKeyboardMac/MacRootView.swift
+++ b/OSGKeyboardMac/MacRootView.swift
@@ -49,7 +49,13 @@ struct MacRootView: View {
brandHeader
VStack(spacing: 4) {
ForEach(MacSection.allCases) { section in
- sidebarRow(section)
+ MacSidebarRow(
+ section: section,
+ isSelected: viewModel.selectedSection == section,
+ language: uiLanguage
+ ) {
+ withAnimation(Motion.soft) { viewModel.selectedSection = section }
+ }
}
}
.padding(.horizontal, MacMetrics.sidebarInset)
@@ -58,27 +64,6 @@ struct MacRootView: View {
}
}
- private func sidebarRow(_ section: MacSection) -> some View {
- let isSelected = viewModel.selectedSection == section
-
- return Button {
- viewModel.selectedSection = section
- } label: {
- Label(section.title(language: uiLanguage), 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(
- isSelected ? palette.accent : Color.clear,
- in: RoundedRectangle(cornerRadius: 7, style: .continuous)
- )
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- }
-
/// 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 {
@@ -123,8 +108,49 @@ struct MacRootView: View {
}
}
.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
+ }
+}
diff --git a/OSGKeyboardMac/MacSettingsView.swift b/OSGKeyboardMac/MacSettingsView.swift
index a4b607a..2cb8fe8 100644
--- a/OSGKeyboardMac/MacSettingsView.swift
+++ b/OSGKeyboardMac/MacSettingsView.swift
@@ -16,6 +16,8 @@ struct MacSettingsView: View {
@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
@@ -30,22 +32,26 @@ struct MacSettingsView: View {
]
var body: some View {
- Form {
- generalSection
- recognitionSection
- if viewModel.config.engineMode == "cloud" {
- providerSection
+ NavigationStack {
+ Form {
+ generalSection
+ recognitionSection
+ if viewModel.config.engineMode == "cloud" {
+ providerSection
+ .transition(.opacity)
+ }
+ if viewModel.config.engineMode == "local" {
+ MacLocalASRModelSettingsView(viewModel: viewModel)
+ .transition(.opacity)
+ }
+ inputSection
+ legalSection
}
- if viewModel.config.engineMode == "local" {
- MacLocalASRModelSettingsView(viewModel: viewModel)
- }
- inputSection
- syncSection
+ .formStyle(.grouped)
+ .tint(palette.accent)
+ .scrollContentBackground(.hidden)
+ .background(palette.background)
}
- .formStyle(.grouped)
- .tint(palette.accent)
- .scrollContentBackground(.hidden)
- .background(palette.background)
.onAppear { refreshAccessibilityState() }
}
@@ -70,13 +76,7 @@ struct MacSettingsView: View {
Text(localeLabel(locale)).tag(locale.id)
}
}
- }
- }
- // MARK: - iCloud
-
- private var syncSection: some View {
- Section("iCloud") {
MacSettingsICloudSyncRow(defaults: viewModel.defaults, language: lang)
MacDictionaryICloudSyncRow(defaults: viewModel.defaults, language: lang)
}
@@ -139,14 +139,14 @@ struct MacSettingsView: View {
subtitle: MacL10n.string("mac.settings.cloudEngineDesc", language: lang),
systemImage: "cloud",
selected: viewModel.config.engineMode == "cloud"
- ) { viewModel.setEngineMode("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"
- ) { viewModel.setEngineMode("local") }
+ ) { withAnimation(Motion.soft) { viewModel.setEngineMode("local") } }
}
}
@@ -176,6 +176,8 @@ struct MacSettingsView: View {
)
.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()
@@ -190,7 +192,27 @@ struct MacSettingsView: View {
}
}
- // MARK: - Qwen3 model path (legacy — see MacLocalASRModelSettingsView)
+ // 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
@@ -228,7 +250,9 @@ struct MacSettingsView: View {
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)
diff --git a/OSGKeyboardMac/MacSherpaLocalASR.swift b/OSGKeyboardMac/MacSherpaLocalASR.swift
index 94b632e..6bb7762 100644
--- a/OSGKeyboardMac/MacSherpaLocalASR.swift
+++ b/OSGKeyboardMac/MacSherpaLocalASR.swift
@@ -49,6 +49,14 @@ enum MacSherpaLocalASR {
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")
}
diff --git a/OSGKeyboardMac/MacSherpaONNXRunner.swift b/OSGKeyboardMac/MacSherpaONNXRunner.swift
index d4116b8..740da76 100644
--- a/OSGKeyboardMac/MacSherpaONNXRunner.swift
+++ b/OSGKeyboardMac/MacSherpaONNXRunner.swift
@@ -77,6 +77,33 @@ enum MacSherpaONNXRunner {
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 {
diff --git a/OSGKeyboardMac/MacTheme.swift b/OSGKeyboardMac/MacTheme.swift
index 9728001..8dfba7e 100644
--- a/OSGKeyboardMac/MacTheme.swift
+++ b/OSGKeyboardMac/MacTheme.swift
@@ -3,47 +3,108 @@
//
// 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 (`Color(nsColor:)`), which adapt to light / dark on their
-// own. The brand green is kept only as the accent. This gives the app the
-// same zero-colour-difference, System-Settings / Notes look on both
-// appearances while reusing every existing `palette.X` call site.
+// 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 {
- /// A `ThemePalette` whose surfaces and text resolve to AppKit semantic
- /// colours. Because those colours are dynamic, a single value renders
- /// correctly under both light and dark (driven by `preferredColorScheme`).
- static let palette = ThemePalette(
- background: Color(nsColor: .windowBackgroundColor),
- surface: Color(nsColor: .controlBackgroundColor),
- surfaceElevated: Color(nsColor: .unemphasizedSelectedContentBackgroundColor),
- surfaceMuted: Color(nsColor: .underPageBackgroundColor),
+ /// 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
+ }
- accent: Palette.accent,
- accentMuted: Palette.accent.opacity(0.16),
- accentGlow: Palette.accent.opacity(0.35),
+ private static let lightPalette = makePalette(dark: false)
+ private static let darkPalette = makePalette(dark: true)
- danger: Color(nsColor: .systemRed),
- success: Palette.accent,
- warning: Color(nsColor: .systemOrange),
+ 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),
- textPrimary: Color(nsColor: .labelColor),
- textSecondary: Color(nsColor: .secondaryLabelColor),
- textTertiary: Color(nsColor: .tertiaryLabelColor),
- textOnAccent: Color.white,
+ accent: Palette.accent,
+ accentMuted: Palette.accent.opacity(0.16),
+ accentGlow: Palette.accent.opacity(0.35),
- divider: Color(nsColor: .separatorColor),
- dividerStrong: Color(nsColor: .separatorColor),
+ danger: resolved(.systemRed, dark: dark),
+ success: Palette.accent,
+ warning: resolved(.systemOrange, dark: dark),
- recordRed: Color(nsColor: .systemRed)
- )
+ 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.
+ /// Injects the system-native palette used across the macOS app, refreshed
+ /// automatically whenever the effective colour scheme changes.
func macSystemPalette() -> some View {
- environment(\.themePalette, MacSystemPalette.palette)
+ modifier(MacSystemPaletteModifier())
}
}
diff --git a/OSGKeyboardMac/OSGKeyboardMacApp.swift b/OSGKeyboardMac/OSGKeyboardMacApp.swift
index a87c048..5f1af5a 100644
--- a/OSGKeyboardMac/OSGKeyboardMacApp.swift
+++ b/OSGKeyboardMac/OSGKeyboardMacApp.swift
@@ -16,6 +16,7 @@ struct OSGKeyboardMacApp: App {
// 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
@@ -23,7 +24,16 @@ struct OSGKeyboardMacApp: App {
var body: some Scene {
Window("OSGKeyboard", id: "main") {
- MacRootView(viewModel: viewModel)
+ Group {
+ if hasCompletedMacOnboarding {
+ MacRootView(viewModel: viewModel)
+ } else {
+ MacOnboardingView(
+ viewModel: viewModel,
+ hasCompletedOnboarding: $hasCompletedMacOnboarding
+ )
+ }
+ }
.macSystemPalette()
.environment(\.locale, viewModel.config.uiLanguage.swiftUILocale)
.preferredColorScheme(appearance.colorScheme)
@@ -177,12 +187,41 @@ final class MacAppDelegate: NSObject, NSApplicationDelegate {
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 {
- MacContentView(viewModel: viewModel)
+ 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)
+ }
}
diff --git a/OSGKeyboardShared/Models/LocalASRCapabilities.swift b/OSGKeyboardShared/Models/LocalASRCapabilities.swift
index c5e33d5..37743c5 100644
--- a/OSGKeyboardShared/Models/LocalASRCapabilities.swift
+++ b/OSGKeyboardShared/Models/LocalASRCapabilities.swift
@@ -79,4 +79,13 @@ public struct LocalASRCapabilities: Sendable, Equatable {
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
+ )
}
diff --git a/OSGKeyboardShared/Models/LocalASRModelCatalog.swift b/OSGKeyboardShared/Models/LocalASRModelCatalog.swift
index d4f2f12..beb67d1 100644
--- a/OSGKeyboardShared/Models/LocalASRModelCatalog.swift
+++ b/OSGKeyboardShared/Models/LocalASRModelCatalog.swift
@@ -9,19 +9,41 @@ 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 {
@@ -30,6 +52,7 @@ public struct LocalASRModelLayout: Codable, Sendable, Equatable {
public var decoder: String?
public var tokenizer: String?
public var senseVoiceModel: String?
+ public var paraformerModel: String?
public var tokens: String?
}
@@ -91,6 +114,8 @@ public enum LocalASRModelCatalog {
return .sherpaQwen3
case .sherpaSenseVoice:
return .sherpaSenseVoice
+ case .sherpaParaformer:
+ return .sherpaParaformer
case .appleSpeech:
return .appleSpeech
}
@@ -117,6 +142,42 @@ public enum LocalASRModelCatalog {
#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)
diff --git a/OSGKeyboardShared/Resources/LocalASR/local-asr-catalog.json b/OSGKeyboardShared/Resources/LocalASR/local-asr-catalog.json
index 610986b..424219f 100644
--- a/OSGKeyboardShared/Resources/LocalASR/local-asr-catalog.json
+++ b/OSGKeyboardShared/Resources/LocalASR/local-asr-catalog.json
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
- "defaultModelId": "qwen3-mlx-1.7b",
+ "defaultModelId": "sherpa-qwen3-0.6b-int8",
"runtimes": [
{
"id": "sherpa-onnx-1.13.4-macos-arm64",
@@ -36,21 +36,9 @@
}
],
"models": [
- {
- "id": "qwen3-mlx-1.7b",
- "displayName": "Qwen3-ASR 1.7B (MLX)",
- "backend": "mlx",
- "sizeBytes": 1400000000,
- "recommendedLocales": ["zh-CN", "en-US"],
- "supportsHotwords": true,
- "hotwordMode": "promptOnly",
- "installKind": "manual",
- "installRelativePath": "models/qwen3-asr-1.7b-mlx",
- "requiredRelativeFiles": ["config.json", "model.safetensors", "vocab.json", "merges.txt"]
- },
{
"id": "sherpa-qwen3-0.6b-int8",
- "displayName": "Qwen3-ASR 0.6B (Sherpa · hotwords)",
+ "displayName": "Qwen3-ASR 0.6B",
"backend": "sherpaQwen3",
"runtimePlatform": "macos",
"sizeBytes": 650000000,
@@ -74,9 +62,84 @@
}
]
},
+ {
+ "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 (Sherpa)",
+ "displayName": "SenseVoice Small",
"backend": "sherpaSenseVoice",
"runtimePlatform": "macos",
"sizeBytes": 250000000,
diff --git a/OSGKeyboardShared/Services/LocalASRModelInstallState.swift b/OSGKeyboardShared/Services/LocalASRModelInstallState.swift
index 5545c26..6d6bd77 100644
--- a/OSGKeyboardShared/Services/LocalASRModelInstallState.swift
+++ b/OSGKeyboardShared/Services/LocalASRModelInstallState.swift
@@ -25,7 +25,7 @@ public enum LocalASRModelInstallState {
let base = URL(fileURLWithPath: manualMLXPath ?? "", isDirectory: true)
guard fileManager.fileExists(atPath: base.path) else { return false }
return required.allSatisfy { fileManager.fileExists(atPath: base.appendingPathComponent($0).path) }
- case .archive:
+ case .archive, .repository:
guard let relative = model.installRelativePath,
let layout = model.layout,
let baseName = model.archiveBaseName else { return false }
@@ -41,7 +41,7 @@ public enum LocalASRModelInstallState {
_ model: LocalASRModelDefinition,
fileManager: FileManager = .default
) -> URL? {
- guard model.installKind == .archive,
+ guard model.installKind == .archive || model.installKind == .repository,
let relative = model.installRelativePath,
let baseName = model.archiveBaseName else { return nil }
return installDirectory(for: relative, fileManager: fileManager)
@@ -98,6 +98,11 @@ public enum LocalASRModelInstallState {
let tokens = layout.tokens else { return false }
return fileManager.fileExists(atPath: root.appendingPathComponent(onnx).path)
&& fileManager.fileExists(atPath: root.appendingPathComponent(tokens).path)
+ case .sherpaParaformer:
+ guard let paraformer = layout.paraformerModel,
+ let tokens = layout.tokens else { return false }
+ return fileManager.fileExists(atPath: root.appendingPathComponent(paraformer).path)
+ && fileManager.fileExists(atPath: root.appendingPathComponent(tokens).path)
default:
return false
}
diff --git a/OSGKeyboardShared/Services/LocalASRModelManager.swift b/OSGKeyboardShared/Services/LocalASRModelManager.swift
index 022fbe4..a0580eb 100644
--- a/OSGKeyboardShared/Services/LocalASRModelManager.swift
+++ b/OSGKeyboardShared/Services/LocalASRModelManager.swift
@@ -184,9 +184,7 @@ public actor LocalASRModelManager {
_ model: LocalASRModelDefinition,
catalog: LocalASRCatalogDocument
) async throws {
- guard model.installKind == .archive,
- let relative = model.installRelativePath,
- let baseName = model.archiveBaseName,
+ guard let relative = model.installRelativePath,
let sources = model.sources,
!sources.isEmpty else {
throw LocalASRModelManagerError.validationFailed("Model is not downloadable.")
@@ -198,38 +196,70 @@ public actor LocalASRModelManager {
message: model.displayName,
activeItemId: model.id
)
- if model.backend == .sherpaQwen3 || model.backend == .sherpaSenseVoice {
+ if model.backend == .sherpaQwen3 || model.backend == .sherpaSenseVoice || model.backend == .sherpaParaformer {
try await ensureRuntimeInstalled(catalog: catalog)
}
- let sortedSources = sources.sorted { $0.priority < $1.priority }
+
+ let sortedSources = LocalASRDownloadSourceSorter.sorted(sources)
var lastError: Error?
- for source in sortedSources {
- do {
- try await installArchive(
- from: source.url,
- installRelativePath: relative,
- archiveBaseName: baseName,
- layoutModel: model,
- itemId: model.id,
- displayName: model.displayName
- )
- var manifest = loadManifest(defaultModelId: catalog.defaultModelId)
- if !manifest.installedModelIDs.contains(model.id) {
- manifest.installedModelIDs.append(model.id)
- }
- manifest.updatedAt = Date()
- try saveManifest(manifest)
- progress = LocalASRModelInstallProgress(
- phase: .completed,
- fraction: 1,
- message: model.displayName,
- activeItemId: model.id
- )
- return
- } catch {
- lastError = error
+
+ switch model.installKind {
+ case .archive:
+ guard let baseName = model.archiveBaseName else {
+ throw LocalASRModelManagerError.validationFailed("Model is not downloadable.")
}
+ for source in sortedSources where source.isArchive {
+ do {
+ try await installArchive(
+ from: source.url,
+ installRelativePath: relative,
+ archiveBaseName: baseName,
+ layoutModel: model,
+ itemId: model.id,
+ displayName: model.displayName
+ )
+ try markModelInstalled(model, catalog: catalog)
+ progress = LocalASRModelInstallProgress(
+ phase: .completed,
+ fraction: 1,
+ message: model.displayName,
+ activeItemId: model.id
+ )
+ return
+ } catch {
+ lastError = error
+ }
+ }
+ case .repository:
+ guard let baseName = model.archiveBaseName else {
+ throw LocalASRModelManagerError.validationFailed("Model is not downloadable.")
+ }
+ for source in sortedSources where source.isRepository {
+ do {
+ try await installRepository(
+ source: source,
+ installRelativePath: relative,
+ archiveBaseName: baseName,
+ layoutModel: model,
+ itemId: model.id,
+ displayName: model.displayName
+ )
+ try markModelInstalled(model, catalog: catalog)
+ progress = LocalASRModelInstallProgress(
+ phase: .completed,
+ fraction: 1,
+ message: model.displayName,
+ activeItemId: model.id
+ )
+ return
+ } catch {
+ lastError = error
+ }
+ }
+ default:
+ throw LocalASRModelManagerError.validationFailed("Model is not downloadable.")
}
+
progress = LocalASRModelInstallProgress(
phase: .failed,
fraction: 0,
@@ -238,11 +268,24 @@ public actor LocalASRModelManager {
throw lastError ?? LocalASRModelManagerError.downloadFailed("All mirrors failed")
}
+ private func markModelInstalled(
+ _ model: LocalASRModelDefinition,
+ catalog: LocalASRCatalogDocument
+ ) throws {
+ var manifest = loadManifest(defaultModelId: catalog.defaultModelId)
+ if !manifest.installedModelIDs.contains(model.id) {
+ manifest.installedModelIDs.append(model.id)
+ }
+ manifest.updatedAt = Date()
+ try saveManifest(manifest)
+ }
+
public func installRuntime(
_ runtime: LocalASRRuntimeDefinition,
catalog: LocalASRCatalogDocument
) async throws {
- guard let source = runtime.sources.sorted(by: { $0.priority < $1.priority }).first else {
+ let sortedSources = LocalASRDownloadSourceSorter.sorted(runtime.sources)
+ guard !sortedSources.isEmpty else {
throw LocalASRModelManagerError.downloadFailed("No runtime source configured.")
}
progress = LocalASRModelInstallProgress(
@@ -251,25 +294,34 @@ public actor LocalASRModelManager {
message: runtime.displayName,
activeItemId: runtime.id
)
- try await installArchive(
- from: source.url,
- installRelativePath: runtime.installRelativePath,
- archiveBaseName: runtime.installRelativePath.split(separator: "/").last.map(String.init) ?? runtime.id,
- layoutModel: nil,
- expectedBinaryCandidates: runtime.binaryCandidates,
- itemId: runtime.id,
- displayName: runtime.displayName
- )
- guard isRuntimeInstalled(runtime) else {
- throw LocalASRModelManagerError.binaryMissing
+ var lastError: Error?
+ for source in sortedSources where source.isArchive {
+ do {
+ try await installArchive(
+ from: source.url,
+ installRelativePath: runtime.installRelativePath,
+ archiveBaseName: runtime.installRelativePath.split(separator: "/").last.map(String.init) ?? runtime.id,
+ layoutModel: nil,
+ expectedBinaryCandidates: runtime.binaryCandidates,
+ itemId: runtime.id,
+ displayName: runtime.displayName
+ )
+ guard isRuntimeInstalled(runtime) else {
+ throw LocalASRModelManagerError.binaryMissing
+ }
+ var manifest = loadManifest(defaultModelId: catalog.defaultModelId)
+ if !manifest.installedRuntimeIDs.contains(runtime.id) {
+ manifest.installedRuntimeIDs.append(runtime.id)
+ }
+ manifest.updatedAt = Date()
+ try saveManifest(manifest)
+ progress = LocalASRModelInstallProgress(phase: .completed, fraction: 1, message: runtime.displayName)
+ return
+ } catch {
+ lastError = error
+ }
}
- var manifest = loadManifest(defaultModelId: catalog.defaultModelId)
- if !manifest.installedRuntimeIDs.contains(runtime.id) {
- manifest.installedRuntimeIDs.append(runtime.id)
- }
- manifest.updatedAt = Date()
- try saveManifest(manifest)
- progress = LocalASRModelInstallProgress(phase: .completed, fraction: 1, message: runtime.displayName)
+ throw lastError ?? LocalASRModelManagerError.downloadFailed("All runtime mirrors failed")
}
public func modelRootURL(_ model: LocalASRModelDefinition) -> URL? {
@@ -285,7 +337,8 @@ public actor LocalASRModelManager {
_ model: LocalASRModelDefinition,
catalog: LocalASRCatalogDocument
) throws {
- guard model.installKind == .archive, let relative = model.installRelativePath else { return }
+ guard model.installKind == .archive || model.installKind == .repository,
+ let relative = model.installRelativePath else { return }
let dir = installDirectory(for: relative)
if fileManager.fileExists(atPath: dir.path) {
try fileManager.removeItem(at: dir)
@@ -445,6 +498,115 @@ public actor LocalASRModelManager {
try? fileManager.removeItem(at: archiveURL)
}
+ private func installRepository(
+ source: LocalASRDownloadSource,
+ installRelativePath: String,
+ archiveBaseName: String,
+ layoutModel: LocalASRModelDefinition,
+ itemId: String,
+ displayName: String
+ ) async throws {
+ guard let baseURL = source.baseURL,
+ let files = source.files,
+ !files.isEmpty else {
+ throw LocalASRModelManagerError.downloadFailed("Invalid repository source.")
+ }
+
+ let destinationRoot = installDirectory(for: installRelativePath)
+ .appendingPathComponent(archiveBaseName, isDirectory: true)
+ let stagingRoot = rootDirectory().appendingPathComponent("staging/\(UUID().uuidString)", isDirectory: true)
+ try fileManager.createDirectory(at: stagingRoot, withIntermediateDirectories: true)
+ defer { try? fileManager.removeItem(at: stagingRoot) }
+
+ if fileManager.fileExists(atPath: destinationRoot.path) {
+ try fileManager.removeItem(at: destinationRoot)
+ }
+ try fileManager.createDirectory(at: destinationRoot, withIntermediateDirectories: true)
+
+ let totalBytes = files.reduce(Int64(0)) { partial, file in
+ partial + Int64(file.sizeBytes ?? 0)
+ }
+ var completedBytes: Int64 = 0
+
+ for (index, file) in files.enumerated() {
+ let remoteURLString = baseURL.replacingOccurrences(of: "{path}", with: file.remotePath)
+ guard let remoteURL = URL(string: remoteURLString) else {
+ throw LocalASRModelManagerError.downloadFailed("Invalid URL for \(file.remotePath)")
+ }
+
+ let localURL = destinationRoot.appendingPathComponent(file.localPath)
+ try fileManager.createDirectory(
+ at: localURL.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+
+ // Snapshot the running total so the progress closure captures an
+ // immutable value (avoids concurrent access to `completedBytes`).
+ let priorBytes = completedBytes
+ progress = LocalASRModelInstallProgress(
+ phase: .downloading,
+ fraction: 0.10 + (Double(index) / Double(files.count)) * 0.45,
+ message: displayName,
+ bytesReceived: priorBytes,
+ bytesTotal: totalBytes > 0 ? totalBytes : nil,
+ activeItemId: itemId
+ )
+
+ do {
+ let controller = LocalASRModelDownloadClient.makeController(destinationURL: localURL) { update in
+ Task {
+ let aggregateReceived = priorBytes + update.bytesReceived
+ let aggregateTotal = totalBytes > 0 ? totalBytes : update.bytesTotal
+ await LocalASRModelManager.shared.updateDownloadProgress(
+ itemId: itemId,
+ displayName: displayName,
+ update: LocalASRDownloadProgressUpdate(
+ bytesReceived: aggregateReceived,
+ bytesTotal: max(aggregateTotal, 1)
+ )
+ )
+ }
+ }
+ activeDownloadController = controller
+ try await controller.download(from: remoteURL)
+ activeDownloadController = nil
+ pausedResumeData = nil
+ } catch {
+ activeDownloadController = nil
+ pausedResumeData = nil
+ throw LocalASRModelManagerError.downloadFailed(error.localizedDescription)
+ }
+
+ if let size = file.sizeBytes {
+ completedBytes += Int64(size)
+ } else if let attrs = try? fileManager.attributesOfItem(atPath: localURL.path),
+ let size = attrs[.size] as? Int64 {
+ completedBytes += size
+ }
+ }
+
+ progress = LocalASRModelInstallProgress(
+ phase: .validating,
+ fraction: 0.82,
+ message: displayName,
+ activeItemId: itemId
+ )
+ guard LocalASRModelInstallState.isInstalled(
+ layoutModel,
+ manualMLXPath: nil,
+ fileManager: fileManager
+ ) else {
+ throw LocalASRModelManagerError.validationFailed("Required model files missing after download.")
+ }
+
+ progress = LocalASRModelInstallProgress(
+ phase: .finalizing,
+ fraction: 0.95,
+ message: displayName,
+ activeItemId: itemId
+ )
+ }
+
public func ensureRuntimeInstalled(catalog: LocalASRCatalogDocument) async throws {
guard let runtime = LocalASRModelCatalog.runtime(
for: LocalASRModelCatalog.currentRuntimePlatform(),
diff --git a/OSGKeyboardShared/en.lproj/Shared.strings b/OSGKeyboardShared/en.lproj/Shared.strings
index 218bd11..b8509ed 100644
--- a/OSGKeyboardShared/en.lproj/Shared.strings
+++ b/OSGKeyboardShared/en.lproj/Shared.strings
@@ -162,6 +162,10 @@
"mac.settings.localSpeechFallback" = "Local Recognition (Apple Speech)";
"mac.settings.localSpeechFallbackDesc" = "On-device Apple Speech when the Qwen3 model is not installed.";
"mac.settings.about" = "About";
+"mac.settings.privacyPolicy" = "Privacy Policy";
+"mac.settings.thirdPartyLicenses" = "Third-Party Licenses";
+"mac.settings.licenses.footer" = "Open-source components used by OSGKeyboard. License texts are reproduced from upstream repositories; model weights are downloaded at runtime and cached on device. OSGKeyboard itself is source-available — commercial licensing: rocky.hk@gmail.com.";
+"mac.settings.restartOnboarding" = "Restart First-Run Setup";
"mac.settings.general" = "General";
"mac.settings.input" = "Input & Shortcuts";
"mac.settings.recognitionLanguage" = "Recognition Language";
@@ -185,14 +189,48 @@
"mac.appearance.system" = "System";
"mac.appearance.light" = "Light";
"mac.appearance.dark" = "Dark";
+"mac.onboarding.progress" = "Step %lld of %lld";
+"mac.onboarding.back" = "Back";
+"mac.onboarding.next" = "Next";
+"mac.onboarding.finish" = "Finish";
+"mac.onboarding.skipForNow" = "Skip for now";
+"mac.onboarding.welcome.title" = "Welcome to OSGKeyboard";
+"mac.onboarding.welcome.subtitle" = "Set up dictation in a minute. Everything here can be changed later in Settings.";
+"mac.onboarding.welcome.privacy" = "Your voice stays local unless you choose a cloud provider.";
+"mac.onboarding.welcome.hotkey" = "Hold Option to dictate from any app.";
+"mac.onboarding.welcome.local" = "Local Sherpa recognition can be prepared now or later.";
+"mac.onboarding.microphone.title" = "Allow microphone access";
+"mac.onboarding.microphone.subtitle" = "OSGKeyboard needs the microphone to capture your dictation audio.";
+"mac.onboarding.microphone.allow" = "Allow Microphone";
+"mac.onboarding.microphone.granted" = "Microphone access is granted.";
+"mac.onboarding.microphone.needed" = "Microphone access is needed for recording.";
+"mac.onboarding.accessibility.title" = "Enable Accessibility";
+"mac.onboarding.accessibility.subtitle" = "Accessibility lets OSGKeyboard listen for the global shortcut and paste text into the front app. You can skip it and grant it later.";
+"mac.onboarding.accessibility.open" = "Open System Settings";
+"mac.onboarding.accessibility.granted" = "Accessibility permission is granted.";
+"mac.onboarding.accessibility.needed" = "Accessibility is not enabled yet.";
+"mac.onboarding.engine.title" = "Choose a recognition path";
+"mac.onboarding.engine.subtitle" = "Start locally for privacy, or use your own cloud API for provider-based recognition and polish.";
+"mac.onboarding.engine.localDesc" = "Private on-device recognition with Sherpa models. Falls back to Apple Speech until a model is installed.";
+"mac.onboarding.engine.cloudDesc" = "Use your API key for cloud speech recognition and AI polishing.";
+"mac.onboarding.cloud.title" = "Configure API access";
+"mac.onboarding.cloud.subtitle" = "Pick a provider and add your key. This step is optional, so you can finish now and fill it later in Settings.";
+"mac.onboarding.cloud.skipHint" = "You can leave these blank and finish setup; cloud mode will ask for a key before use.";
+"mac.onboarding.model.title" = "Prepare local recognition";
+"mac.onboarding.model.subtitle" = "Download the basic Sherpa runtime and default model now, or skip and continue with Apple Speech fallback.";
+"mac.onboarding.model.download" = "Download Default Model";
+"mac.onboarding.model.done" = "Local model is ready.";
+"mac.onboarding.model.skipHint" = "Skipping is safe: Settings keeps the same download controls for later.";
+"mac.onboarding.popover.title" = "Finish setup";
+"mac.onboarding.popover.subtitle" = "Open the main window to choose permissions, API settings, and local model download.";
"mac.error.noAudio" = "No audio captured";
"mac.error.noCloudASR" = "Selected provider has no cloud ASR";
"mac.error.emptyTranscript" = "No speech recognized";
"mac.error.qwen3ModelMissing" = "Qwen3-ASR model not installed";
"mac.error.qwen3LoadFailed" = "Failed to load Qwen3 model: %@";
"mac.error.qwen3InferenceFailed" = "Qwen3 transcription failed: %@";
-"mac.localASR.models" = "Local ASR Models";
-"mac.localASR.modelsDesc" = "Sherpa models download directly. For MLX Qwen3, drop your converted weights into the folder opened by “Open folder”. All three share one storage directory.";
+"mac.localASR.models" = "Local ASR Engine & Models";
+"mac.localASR.modelsDesc" = "Models download directly. China uses ModelScope first; elsewhere Hugging Face first, then GitHub. Qwen3 1.7B downloads as multiple files.";
"mac.localASR.download" = "Download";
"mac.localASR.selectFolder" = "Choose folder";
"mac.localASR.openFolder" = "Open folder";
@@ -202,6 +240,7 @@
"mac.localASR.installDone" = "Install completed.";
"mac.localASR.installed" = "Installed";
"mac.localASR.notInstalled" = "Not installed";
+"mac.localASR.personalDictionaryTag" = "Personal dictionary";
"mac.localASR.hotwordsYes" = "Hotwords";
"mac.localASR.hotwordsNo" = "No hotwords";
"mac.localASR.catalogMissing" = "Local ASR catalog is missing from the app bundle.";
@@ -219,8 +258,8 @@
"mac.localASR.redownload" = "Re-download";
"mac.localASR.revealInFinder" = "Reveal in Finder";
"mac.localASR.openStorage" = "Open model storage folder";
-"mac.localASR.runtime" = "Sherpa Runtime";
-"mac.localASR.runtimeDesc" = "Required for Sherpa Qwen3 and SenseVoice models. Installed automatically with those models.";
+"mac.localASR.runtime" = "Local ASR runtime";
+"mac.localASR.runtimeDesc" = "Installed automatically with the models above; required for Qwen3, SenseVoice, and similar models.";
"mac.localASR.phase.downloading" = "Downloading";
"mac.localASR.phase.paused" = "Paused";
"mac.localASR.phase.extracting" = "Extracting";
diff --git a/OSGKeyboardShared/zh-Hans.lproj/Shared.strings b/OSGKeyboardShared/zh-Hans.lproj/Shared.strings
index 5562931..771ca39 100644
--- a/OSGKeyboardShared/zh-Hans.lproj/Shared.strings
+++ b/OSGKeyboardShared/zh-Hans.lproj/Shared.strings
@@ -162,6 +162,10 @@
"mac.settings.localSpeechFallback" = "本地识别(Apple Speech)";
"mac.settings.localSpeechFallbackDesc" = "未安装 Qwen3 模型时,使用 Apple 本地语音识别。";
"mac.settings.about" = "关于";
+"mac.settings.privacyPolicy" = "隐私政策";
+"mac.settings.thirdPartyLicenses" = "第三方许可";
+"mac.settings.licenses.footer" = "以下为 OSGKeyboard 使用的开源组件。许可正文摘自上游仓库;模型权重在运行时下载并缓存在本机。OSGKeyboard 本身采用源码可见许可,商业授权请联系 rocky.hk@gmail.com。";
+"mac.settings.restartOnboarding" = "重新开始首次引导";
"mac.settings.general" = "通用";
"mac.settings.input" = "输入与快捷键";
"mac.settings.recognitionLanguage" = "识别语言";
@@ -185,14 +189,48 @@
"mac.appearance.system" = "跟随系统";
"mac.appearance.light" = "浅色";
"mac.appearance.dark" = "深色";
+"mac.onboarding.progress" = "第 %lld 步,共 %lld 步";
+"mac.onboarding.back" = "上一步";
+"mac.onboarding.next" = "下一步";
+"mac.onboarding.finish" = "完成";
+"mac.onboarding.skipForNow" = "暂时跳过";
+"mac.onboarding.welcome.title" = "欢迎使用 OSGKeyboard";
+"mac.onboarding.welcome.subtitle" = "用一分钟完成听写基础配置。这里的选项之后都可以在设置里修改。";
+"mac.onboarding.welcome.privacy" = "除非你选择云端服务商,语音会优先留在本机处理。";
+"mac.onboarding.welcome.hotkey" = "按住 Option 键即可在任意应用开始听写。";
+"mac.onboarding.welcome.local" = "Sherpa 本地识别可以现在准备,也可以稍后下载。";
+"mac.onboarding.microphone.title" = "允许麦克风访问";
+"mac.onboarding.microphone.subtitle" = "OSGKeyboard 需要麦克风来录制你的听写音频。";
+"mac.onboarding.microphone.allow" = "允许麦克风";
+"mac.onboarding.microphone.granted" = "麦克风权限已授权。";
+"mac.onboarding.microphone.needed" = "录音需要麦克风权限。";
+"mac.onboarding.accessibility.title" = "开启辅助功能";
+"mac.onboarding.accessibility.subtitle" = "辅助功能用于全局快捷键和向前台应用粘贴文本。你可以先跳过,之后再授权。";
+"mac.onboarding.accessibility.open" = "打开系统设置";
+"mac.onboarding.accessibility.granted" = "辅助功能权限已授权。";
+"mac.onboarding.accessibility.needed" = "尚未开启辅助功能权限。";
+"mac.onboarding.engine.title" = "选择识别方式";
+"mac.onboarding.engine.subtitle" = "默认可用本地识别以保护隐私,也可以配置自己的云端 API。";
+"mac.onboarding.engine.localDesc" = "使用 Sherpa 本地模型进行私密转写;模型未安装前会回退到 Apple 语音识别。";
+"mac.onboarding.engine.cloudDesc" = "使用你的 API Key 进行云端语音识别和 AI 润色。";
+"mac.onboarding.cloud.title" = "配置 API 访问";
+"mac.onboarding.cloud.subtitle" = "选择服务商并填写密钥。此步骤可跳过,之后可在设置中补齐。";
+"mac.onboarding.cloud.skipHint" = "可以先留空并完成引导;云端模式使用前会提示需要 API Key。";
+"mac.onboarding.model.title" = "准备本地识别";
+"mac.onboarding.model.subtitle" = "现在下载基础 Sherpa runtime 和默认模型,或先跳过并使用 Apple Speech 兜底。";
+"mac.onboarding.model.download" = "下载默认模型";
+"mac.onboarding.model.done" = "本地模型已准备好。";
+"mac.onboarding.model.skipHint" = "跳过不会影响使用:设置页保留同样的下载入口。";
+"mac.onboarding.popover.title" = "完成首次配置";
+"mac.onboarding.popover.subtitle" = "打开主窗口选择权限、API 设置和本地模型下载。";
"mac.error.noAudio" = "没有捕获到音频";
"mac.error.noCloudASR" = "当前服务商不支持云端语音识别";
"mac.error.emptyTranscript" = "没有识别到语音";
"mac.error.qwen3ModelMissing" = "未安装 Qwen3-ASR 模型";
"mac.error.qwen3LoadFailed" = "Qwen3 模型加载失败:%@";
"mac.error.qwen3InferenceFailed" = "Qwen3 转写失败:%@";
-"mac.localASR.models" = "本地 ASR 模型";
-"mac.localASR.modelsDesc" = "Sherpa 模型可直接下载;MLX Qwen3 请将转换好的权重放入「打开目录」指向的文件夹。三个模型共用同一存储目录。";
+"mac.localASR.models" = "本地 ASR 引擎与模型";
+"mac.localASR.modelsDesc" = "模型可直接下载。中国大陆优先 ModelScope,其他地区优先 Hugging Face,再回退 GitHub。Qwen3 1.7B 以多文件方式下载。";
"mac.localASR.download" = "下载";
"mac.localASR.selectFolder" = "选择目录";
"mac.localASR.openFolder" = "打开目录";
@@ -202,6 +240,7 @@
"mac.localASR.installDone" = "安装完成。";
"mac.localASR.installed" = "已安装";
"mac.localASR.notInstalled" = "未安装";
+"mac.localASR.personalDictionaryTag" = "个性词库";
"mac.localASR.hotwordsYes" = "支持热词";
"mac.localASR.hotwordsNo" = "无热词";
"mac.localASR.catalogMissing" = "应用包内缺少本地 ASR 模型目录。";
@@ -219,8 +258,8 @@
"mac.localASR.redownload" = "重新下载";
"mac.localASR.revealInFinder" = "在 Finder 中显示";
"mac.localASR.openStorage" = "打开模型存储目录";
-"mac.localASR.runtime" = "Sherpa 运行时";
-"mac.localASR.runtimeDesc" = "Sherpa Qwen3 与 SenseVoice 模型需要此运行时;下载上述模型时会自动安装。";
+"mac.localASR.runtime" = "本地识别运行时";
+"mac.localASR.runtimeDesc" = "下载上述模型时会自动安装;Qwen3 与 SenseVoice 等模型需要此组件。";
"mac.localASR.phase.downloading" = "下载中";
"mac.localASR.phase.paused" = "已暂停";
"mac.localASR.phase.extracting" = "解压中";
diff --git a/OSGKeyboardTests/LocalASRDownloadSourceSorterTests.swift b/OSGKeyboardTests/LocalASRDownloadSourceSorterTests.swift
new file mode 100644
index 0000000..0376c1c
--- /dev/null
+++ b/OSGKeyboardTests/LocalASRDownloadSourceSorterTests.swift
@@ -0,0 +1,47 @@
+// LocalASRDownloadSourceSorterTests.swift
+// OSGKeyboardTests
+
+import XCTest
+@testable import OSGKeyboardShared
+
+final class LocalASRDownloadSourceSorterTests: XCTestCase {
+
+ private func source(_ type: String, priority: Int = 1) -> LocalASRDownloadSource {
+ LocalASRDownloadSource(
+ type: type,
+ priority: priority,
+ url: "https://example.com/\(type).tar.bz2",
+ baseURL: nil,
+ files: nil
+ )
+ }
+
+ func testChinaMainlandPrefersModelScope() {
+ let sources = [
+ source("github"),
+ source("huggingface"),
+ source("modelscope"),
+ ]
+ let sorted = LocalASRDownloadSourceSorter.sorted(sources, region: Locale.Region("CN"))
+ XCTAssertEqual(sorted.map(\.type), ["modelscope", "huggingface", "github"])
+ }
+
+ func testGlobalPrefersHuggingFace() {
+ let sources = [
+ source("github"),
+ source("huggingface"),
+ source("modelscope"),
+ ]
+ let sorted = LocalASRDownloadSourceSorter.sorted(sources, region: Locale.Region("US"))
+ XCTAssertEqual(sorted.map(\.type), ["huggingface", "github", "modelscope"])
+ }
+
+ func testSameTypeUsesPriority() {
+ let sources = [
+ source("github", priority: 2),
+ source("github", priority: 1),
+ ]
+ let sorted = LocalASRDownloadSourceSorter.sorted(sources, region: Locale.Region("US"))
+ XCTAssertEqual(sorted.map(\.priority), [1, 2])
+ }
+}
diff --git a/OSGKeyboardTests/LocalASRModelCatalogTests.swift b/OSGKeyboardTests/LocalASRModelCatalogTests.swift
index 4e13d7a..8a913cc 100644
--- a/OSGKeyboardTests/LocalASRModelCatalogTests.swift
+++ b/OSGKeyboardTests/LocalASRModelCatalogTests.swift
@@ -9,9 +9,19 @@ final class LocalASRModelCatalogTests: XCTestCase {
func testBundledCatalogLoads() throws {
let catalog = try LocalASRModelCatalog.loadBundled()
XCTAssertEqual(catalog.schemaVersion, 1)
- XCTAssertFalse(catalog.models.isEmpty)
- XCTAssertTrue(catalog.models.contains { $0.id == "qwen3-mlx-1.7b" })
+ XCTAssertEqual(catalog.defaultModelId, "sherpa-qwen3-0.6b-int8")
+ XCTAssertFalse(catalog.models.contains { $0.id == "qwen3-mlx-1.7b" })
XCTAssertTrue(catalog.models.contains { $0.id == "sherpa-qwen3-0.6b-int8" })
+ XCTAssertTrue(catalog.models.contains { $0.id == "sherpa-qwen3-1.7b-int8" })
+ XCTAssertTrue(catalog.models.contains { $0.id == "sherpa-paraformer-zh-int8" })
+ }
+
+ func testSherpaQwen317BUsesRepositoryInstall() throws {
+ let catalog = try LocalASRModelCatalog.loadBundled()
+ let model = try XCTUnwrap(LocalASRModelCatalog.model("sherpa-qwen3-1.7b-int8", in: catalog))
+ XCTAssertEqual(model.installKind, .repository)
+ XCTAssertTrue(model.sources?.contains(where: { $0.type == "modelscope" && $0.isRepository }) == true)
+ XCTAssertTrue(model.sources?.contains(where: { $0.type == "huggingface" && $0.isRepository }) == true)
}
func testCapabilitiesForSherpaQwen3() throws {
@@ -22,6 +32,14 @@ final class LocalASRModelCatalogTests: XCTestCase {
XCTAssertTrue(model.supportsHotwords)
}
+ func testCapabilitiesForParaformer() throws {
+ let catalog = try LocalASRModelCatalog.loadBundled()
+ let model = try XCTUnwrap(LocalASRModelCatalog.model("sherpa-paraformer-zh-int8", in: catalog))
+ let caps = LocalASRModelCatalog.capabilities(for: model)
+ XCTAssertEqual(caps.hotwordMode, .none)
+ XCTAssertFalse(model.supportsHotwords)
+ }
+
func testManifestRoundTrip() throws {
let manifest = LocalASRInstalledManifest(
selectedModelId: "sherpa-qwen3-0.6b-int8",
@@ -54,11 +72,11 @@ final class LocalASRModelCatalogTests: XCTestCase {
)
LocalASRBiasDiagnosticsStore.save(
payload: payload,
- modelId: "qwen3-mlx-1.7b",
- backendLabel: "MLX"
+ modelId: "sherpa-qwen3-0.6b-int8",
+ backendLabel: "Sherpa Qwen3"
)
let snapshot = LocalASRBiasDiagnosticsStore.load()
- XCTAssertEqual(snapshot?.modelId, "qwen3-mlx-1.7b")
+ XCTAssertEqual(snapshot?.modelId, "sherpa-qwen3-0.6b-int8")
XCTAssertEqual(snapshot?.diagnostics.userTermCount, 2)
XCTAssertEqual(snapshot?.hotwordCount, 1)
LocalASRBiasDiagnosticsStore.clear()
diff --git a/project.yml b/project.yml
index 70b8ead..30685ed 100644
--- a/project.yml
+++ b/project.yml
@@ -407,6 +407,7 @@ targets:
excludes:
- "AppIcon.appiconset"
- path: OSGKeyboardMac
+ - path: OSGKeyboard/Services/OpenSourceLicenseCatalog.swift
- path: OSGKeyboardShared
excludes:
- "en.lproj"
@@ -432,6 +433,8 @@ targets:
buildPhase: resources
- path: OSGKeyboard/Resources/CustomLanguageModel/v1/phrases.tsv
buildPhase: resources
+ - path: OSGKeyboard/Resources/PrivacyPolicy.html
+ buildPhase: resources
- path: OSGKeyboardShared/Resources/LocalASR/local-asr-catalog.json
buildPhase: resources
entitlements: