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] 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