Merge pull request #31 from hkgood/feature/macos-architecture

Feature/macos architecture
This commit is contained in:
Rocky
2026-07-09 17:46:06 +08:00
committed by GitHub
129 changed files with 12830 additions and 839 deletions
+9
View File
@@ -64,3 +64,12 @@ OSGKeyboard/Resources/CustomLanguageModel/v1/compiled/
# Python bytecode
__pycache__/
*.pyc
# Local debug capture logs and screenshots
.flow-capture.log
.flow-oslog-capture.log
.flow-*.log
.osg_*.png
# Standalone experiment: 灵动岛录音机制验证 demo (throwaway)
AudioIntentProbe/
+27
View File
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- **Force-quit mic release**: on termination the host app now synchronously stops `AVAudioEngine`, deactivates `AVAudioSession`, and ends Live Activities (Dynamic Island + Lock Screen) in `applicationWillTerminate`, reducing “microphone in use” errors after reopening. / **强杀麦克风释放**:进程终止时在 `applicationWillTerminate` 内同步停止 `AVAudioEngine`、释放 `AVAudioSession` 并结束 Live Activity(灵动岛 + 锁屏),降低强杀后重开提示麦克风被占用的概率。
## [0.5.2] - 2026-07-09
### Added
- **macOS local ASR models**: the desktop app ships a bundled model catalog with one-click download of Sherpa Qwen3 (hotwords) and SenseVoice models, plus a shared model storage directory; downloads show a circular progress ring with pause / resume, and each row has inline Download / Delete actions. / **macOS 本地 ASR 模型**:桌面 App 内置模型目录,可一键下载 Sherpa Qwen3(热词)与 SenseVoice 模型,并共用同一模型存储目录;下载显示带暂停 / 继续的环形进度,每行提供内联的下载 / 删除操作。
- **Shared model directory for MLX**: Qwen3-ASR MLX now uses a fixed subfolder inside the shared model storage — drop converted weights into the folder opened by "Open folder"; no per-model directory picker. / **MLX 共用模型目录**Qwen3-ASR MLX 改用共享模型存储中的固定子目录——把转换好的权重放入「打开目录」指向的文件夹即可,不再逐模型选目录。
- **iCloud sync hardening**: per-field settings merge (`appSettings.v2`), per-device usage statistics (G-Counter), tombstoned dictionary/history merge, and a low-risk **Sync Now** action in Settings. / **iCloud 同步加固**:设置按字段合并(`appSettings.v2`)、统计按设备 G-Counter 累计、词库/历史带墓碑合并,并在设置中新增低风险的**立即同步**操作。
### Changed
- **API key sync**: cloud provider API keys now replicate through **iCloud Keychain** when settings sync is on — never through iCloud KVS JSON. / **API 密钥同步**:开启设置同步后,云端服务商 API 密钥改由 **iCloud 钥匙串**复制,不再写入 iCloud KVS JSON。
- **Speech history cap**: synced history limit is **300** entries (aligned with the sync payload). / **语音历史上限**:可同步历史上限为 **300** 条(与同步载荷一致)。
- **macOS app name**: the built product is now `OSGKeyboard.app` (was `OSGKeyboardMac.app`); Dock, About, and Finder all read **OSGKeyboard**. / **macOS 应用名称**:编译产物改为 `OSGKeyboard.app`(原 `OSGKeyboardMac.app`);Dock、关于窗口与 Finder 均显示 **OSGKeyboard**
- **macOS local recognition label**: the Settings entry is now simply "Local Recognition" and no longer names a specific model. / **macOS 本地识别标签**:设置项改为「本地识别」,不再绑定具体模型名称。
### Fixed
- **macOS menu-bar icon in light mode**: the status-bar icon now follows the *system* menu-bar appearance, so forcing the app into Light while the system is Dark no longer renders an unreadable dark icon; a refreshed status mark is used. / **macOS 菜单栏图标(浅色模式)**:状态栏图标改为跟随*系统*菜单栏外观,App 强制浅色而系统为深色时不再出现看不清的深色图标;并更新了状态栏图标。
- **macOS light-mode sidebar**: restored the native translucent sidebar material so the light appearance matches system apps (e.g. System Settings, Notes) instead of a flat grey fill. / **macOS 浅色侧边栏**:恢复原生半透明侧栏材质,浅色外观与系统应用(如系统设置、备忘录)一致,不再是扁平灰底。
- **Settings sync wiping API keys**: pulling a legacy settings blob without API key fields no longer deletes local Keychain entries. / **设置同步清空 API 密钥**:拉取不含 API 密钥字段的旧版设置包时,不再删除本地 Keychain 项。
- **Cross-device settings conflicts**: changing different settings on two devices no longer lets one device's full blob overwrite the other's unrelated fields. / **跨设备设置冲突**:两台设备分别修改不同设置项时,不再因整包覆盖而冲掉对方未改动的字段。
- **Usage statistics under-counting**: offline usage on multiple devices now sums correctly instead of taking per-field `max()`. / **使用统计少计**:多设备离线各自累计后合并为求和,不再对总量取 `max()`
- **Dictionary/history resurrection**: deletes and "clear all" on one device propagate via tombstones so older remote entries cannot come back. / **词库/历史复活**:单设备删除或清空会通过墓碑传播,远端旧条目无法复活。
- **Flow false-ready mic state**: the keyboard mic now stays orange until the host app publishes a real ready contract (capture engine live + polling idle), not merely a fresh heartbeat; green tap-to-talk and jump-to-host behavior share the same `MicVoiceAvailability` gate, and orphaned `stopped` signals self-heal instead of hanging until timeout. / **Flow 伪就绪麦克风状态**:键盘麦克风在主 App 发布真实就绪合约(音频引擎在跑且轮询空闲)之前保持橙色,不再仅凭心跳误判;绿色「点按说话」与跳转主 App 共用同一 `MicVoiceAvailability` 闸门,孤立的 `stopped` 信号会自愈而不再长时间卡住。
- **Flow mic stuck orange after ready**: a single stale cross-process heartbeat read no longer flips a healthy session into a sticky "session ended" error that forced the mic orange. The "session ended" hint now fires only when the (heartbeat-independent) session contract truly drops; a brief read jitter is smoothed by a ready grace window, and a lingering expired hint auto-recovers to green once the host is ready again. / **就绪后麦克风卡橙色**:单次跨进程心跳读数抖动不再把健康会话打成粘滞的「会话已结束」错误、强制麦克风变橙。「会话已结束」提示现仅在(不依赖心跳的)会话合约真正失效时触发;短暂读数抖动由就绪宽限期平滑,遗留的过期提示会在宿主重新就绪后自动恢复为绿色。
- **Orphaned Live Activity after force-quit**: force-quitting the app no longer leaves a stale OSGKeyboard status stuck on the Lock Screen / Dynamic Island. The `staleDate` is now ~45s (refreshed by the heartbeat while the session is alive) so the system reclaims a dead session's island on its own, and every app foreground now sweeps leftover Live Activities *before* trying to (re)start a session — so even a start that later fails (e.g. mic proof timeout) still clears the zombie island. / **强杀后遗留 Live Activity**:强制退出 App 不再在锁屏 / 灵动岛留下无法消失的 OSGKeyboard 状态。`staleDate` 缩短为约 45 秒(会话存活期间由心跳持续刷新),系统会自动回收已死会话的灵动岛;且每次 App 回到前台都会**先**清扫遗留的 Live Activity 再尝试(重新)启动会话——即便本次启动随后失败(如麦克风就绪超时),也不会留下僵尸灵动岛。
## [0.5.0] - 2026-07-07
### Added
+1 -3
View File
@@ -31,8 +31,6 @@
"circles" : [
"watchOS"
],
"squares" : [
"iOS"
]
"squares" : "shared"
}
}
@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "OSGLogoWide.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1,7 @@
<svg width="952" height="291" viewBox="36 367 952 291" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M627.511 512.419C627.511 448.783 576.127 397.197 512.741 397.197C449.355 397.197 397.971 448.783 397.971 512.419C397.971 576.054 449.355 627.641 512.741 627.641V637.837C443.746 637.837 387.814 581.686 387.814 512.419C387.814 443.152 443.746 387 512.741 387C581.736 387 637.668 443.152 637.668 512.419C637.668 581.686 581.736 637.837 512.741 637.837V627.641C576.127 627.641 627.511 576.054 627.511 512.419Z" fill="white"/>
<path d="M56.2632 512.419C56.2632 581.686 112.195 637.837 181.19 637.837C250.185 637.837 306.117 581.686 306.117 512.419C306.117 443.152 250.185 387 181.19 387C112.195 387 56.2632 443.152 56.2632 512.419Z" fill="white"/>
<path d="M399.618 562.094L399.618 552.257L512.74 552.257L512.74 562.094L399.618 562.094Z" fill="white"/>
<path d="M512.741 483.398V473.561H625.864V483.398H512.741Z" fill="white"/>
<path d="M843.308 387.981C910.987 387.981 966.093 441.8 968.171 508.975H843.308V518.812H968.096C965.014 585.066 910.324 637.836 843.308 637.836C774.313 637.836 718.381 581.904 718.381 512.909C718.381 443.914 774.312 387.981 843.308 387.981ZM968.234 518.812H968.096C968.187 516.855 968.234 514.888 968.234 512.909C968.234 511.593 968.211 510.281 968.171 508.975H968.234V518.812Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,25 @@
{
"images" : [
{
"filename" : "OSGStatusMark.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

+10 -10
View File
@@ -22,15 +22,15 @@
<p class="lang"><a href="#zh">中文</a> · <a href="#top">English</a></p>
<div id="top">
<h1>OSGKeyboard Privacy Policy</h1>
<p><strong>Last updated:</strong> June 19, 2026</p>
<p><strong>Last updated:</strong> July 8, 2026</p>
<p>OSGKeyboard is a custom iOS keyboard that turns your voice into text. This policy explains what data the app processes and how it is used.</p>
<h2>What we collect</h2>
<ul>
<li><strong>Voice audio</strong> — captured only while you actively record. On-device mode transcribes locally with Apples speech APIs; raw audio is not uploaded by OSGKeyboard.</li>
<li><strong>Transcribed text</strong> — in Cloud polish mode, the final text (not audio) may be sent to the LLM provider you configure (e.g. OpenAI) for punctuation and formatting.</li>
<li><strong>API credentials</strong> — stored in the iOS Keychain on your device and shared only between the main app and keyboard extension via an App Group.</li>
<li><strong>App preferences</strong> — engine mode, language, and keyboard settings stored in App Group UserDefaults on your device.</li>
<li><strong>API credentials</strong> — stored in the iOS Keychain and shared between the main app and keyboard extension. When iCloud settings sync is enabled, keys replicate through iCloud Keychain (not iCloud KVS JSON).</li>
<li><strong>App preferences</strong> — engine mode, language, and keyboard settings stored in App Group UserDefaults. Optional iCloud sync mirrors preferences, usage statistics, and voice history through your private iCloud account.</li>
</ul>
<h2>What we do not collect</h2>
@@ -51,8 +51,8 @@
<p>When you choose Cloud polish mode, transcribed text is sent to the API endpoint you configure. That providers privacy policy applies to those requests.</p>
<h2>Data retention</h2>
<p>Settings and API keys remain on your device until you delete the app or reset settings. Transcription results are passed to the host app you are typing in and are not stored long-term by OSGKeyboard.</p>
<p><strong>Voice history</strong> — successful transcripts may be saved locally in the main apps History tab for your convenience. This history stays on your device only, is never uploaded, and can be cleared at any time from History or by resetting settings.</p>
<p>Settings remain on your device until you delete the app or reset settings. With iCloud sync enabled, API keys use iCloud Keychain; preferences, statistics, and history may sync via your private iCloud account.</p>
<p><strong>Voice history</strong> — successful transcripts may be saved in the main apps History tab (up to 300 entries). With iCloud settings sync enabled, history may also sync across your devices.</p>
<h2>Contact</h2>
<p>Questions: open an issue at <a href="https://github.com/hkgood/OSGKeyboard">github.com/hkgood/OSGKeyboard</a>.</p>
@@ -60,15 +60,15 @@
<hr id="zh">
<h1>OSGKeyboard 隐私政策</h1>
<p><strong>更新日期:</strong>2026 年 619</p>
<p><strong>更新日期:</strong>2026 年 78</p>
<p>OSGKeyboard 是一款 iOS 自定义键盘,可将语音转为文字。本政策说明应用处理哪些数据及用途。</p>
<h2>我们处理的数据</h2>
<ul>
<li><strong>语音音频</strong> — 仅在你主动录音时采集。本地模式在设备端通过 Apple 语音识别转写,OSGKeyboard 不会上传原始录音。</li>
<li><strong>转写文字</strong> — 云端润色模式下,最终文字(非音频)可能发送到你配置的 LLM 服务商以整理标点和格式。</li>
<li><strong>API 凭证</strong> — 保存在设备 Keychain仅通过 App Group 在主 App 与键盘扩展间共享。</li>
<li><strong>应用偏好</strong> — 引擎、语言等设置保存在设备 App Group</li>
<li><strong>API 凭证</strong> — 保存在设备 Keychain,在主 App 与键盘扩展间共享。开启 iCloud 设置同步后,经 iCloud 钥匙串同步(非 iCloud KVS JSON)。</li>
<li><strong>应用偏好</strong> — 引擎、语言等设置保存在 App Group。可选 iCloud 同步经私有 iCloud 账户镜像偏好、统计与语音历史</li>
</ul>
<h2>我们不收集的内容</h2>
@@ -89,8 +89,8 @@
<p>选择云端润色时,转写文字会发往你配置的 API,该服务商的隐私政策适用于相关请求。</p>
<h2>数据保留</h2>
<p>设置与 API Key 保留在设备上,直至卸载或重置。识别结果写入你正在使用的宿主 App,OSGKeyboard 不会长期存储</p>
<p><strong>语音历史</strong> — 成功转写可保存在主 App「历史」页,仅供本机查看,不会上传,可随时在历史页清空或通过重置设置清除</p>
<p>设置保留在设备上,直至卸载或重置。开启 iCloud 同步后,API 密钥走 iCloud 钥匙串;偏好、统计与历史可能经私有 iCloud 账户同步</p>
<p><strong>语音历史</strong> — 成功转写可保存在主 App「历史」页(最多 300 条)。开启 iCloud 设置同步后,历史也可能在多设备间同步</p>
<h2>联系</h2>
<p>问题反馈:<a href="https://github.com/hkgood/OSGKeyboard">github.com/hkgood/OSGKeyboard</a></p>
+8
View File
@@ -59,6 +59,14 @@ final class AppURLHandler: NSObject, UIApplicationDelegate {
configuration.delegateClass = AppSceneDelegate.self
return configuration
}
/// running 5
/// + Live Activity
func applicationWillTerminate(_ application: UIApplication) {
MainActor.assumeIsolated {
FlowTerminationCoordinator.performSynchronousTerminationCleanup()
}
}
}
final class AppSceneDelegate: NSObject, UIWindowSceneDelegate {
@@ -10,12 +10,16 @@ import OSGKeyboardShared
enum FlowLiveActivityController {
nonisolated(unsafe) private static var currentActivity: Activity<FlowActivityAttributes>?
/// Last phase pushed to the Live Activity so `keepAlive()` can refresh the
/// `staleDate` without changing what the user sees.
nonisolated(unsafe) private static var currentPhase: FlowActivityAttributes.ContentState.Phase = .idle
/// If the host app is force-quit its `endSession()` never runs, orphaning
/// the Live Activity. A `staleDate` lets the system grey it out and become
/// willing to reclaim it without our process refreshed on every update
/// so a genuinely active, in-use session never looks stale.
private static let staleWindow: TimeInterval = 60 * 60
/// the Live Activity. A short `staleDate` lets the system grey it out and
/// reclaim it on its own within ~45s of the process dying. While the host is
/// alive the heartbeat calls `keepAlive()` well inside this window, so a
/// genuinely active session never looks stale.
private static let staleWindow: TimeInterval = 45
private static func freshContent(
phase: FlowActivityAttributes.ContentState.Phase
@@ -41,6 +45,7 @@ enum FlowLiveActivityController {
}
do {
currentPhase = .idle
currentActivity = try Activity.request(
attributes: FlowActivityAttributes(),
content: freshContent(phase: .idle),
@@ -54,14 +59,28 @@ enum FlowLiveActivityController {
static func update(phase: FlowActivityAttributes.ContentState.Phase) {
guard let activity = currentActivity else { return }
currentPhase = phase
let content = freshContent(phase: phase)
Task {
await activity.update(content)
}
}
/// Push a fresh `staleDate` without changing the visible phase. The host
/// heartbeat calls this well inside `staleWindow` so an in-use session
/// never looks stale; once the process dies the refreshes stop and the
/// system reclaims the orphaned Live Activity on its own.
static func keepAlive() {
guard let activity = currentActivity else { return }
let content = freshContent(phase: currentPhase)
Task {
await activity.update(content)
}
}
/// Dismiss the island presentation when the Flow session ends.
static func endSession() {
currentPhase = .idle
guard let activity = currentActivity else {
endStaleActivities()
return
@@ -74,6 +93,18 @@ enum FlowLiveActivityController {
}
}
/// Clear Live Activities orphaned by a previous (force-quit) host process.
///
/// Safe to call on every app foreground: when this process already owns a
/// Live Activity (`currentActivity != nil`) we leave it alone so a healthy
/// running session is never torn down; we only sweep leftovers that belong
/// to a dead process. Call this *before* attempting to (re)start a session
/// so a failed start (e.g. mic timeout) still clears the stale island.
static func clearOrphanedActivities() {
guard currentActivity == nil else { return }
endStaleActivities()
}
/// Host relaunch can leave orphan activities; clear them before starting anew.
private static func endStaleActivities() {
let staleActivities = Activity<FlowActivityAttributes>.activities
@@ -85,4 +116,21 @@ enum FlowLiveActivityController {
}
}
}
/// `applicationWillTerminate` `end` 退
nonisolated static func endAllSynchronouslyOnTerminate() {
let semaphore = DispatchSemaphore(value: 0)
Task.detached(priority: .userInitiated) {
let activities = Activity<FlowActivityAttributes>.activities
let count = activities.count
for activity in activities {
await activity.end(activity.content, dismissalPolicy: .immediate)
}
FlowDiagnostics.log("Live Activity ended synchronously on terminate (count=\(count))")
semaphore.signal()
}
semaphore.wait()
currentPhase = .idle
currentActivity = nil
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
// FlowTerminationCoordinator.swift
// OSGKeyboard · Main App
//
// `UIApplicationDelegate.applicationWillTerminate` `FlowSessionManager`
// SwiftUI `FlowSessionManager` `@StateObject`AppDelegate
// 退 5 Live Activity
import Foundation
@MainActor
enum FlowTerminationCoordinator {
private static weak var sessionManager: FlowSessionManager?
/// `FlowSessionManager.init()`
static func register(_ manager: FlowSessionManager) {
sessionManager = manager
}
/// / 线`applicationWillTerminate`
static func performSynchronousTerminationCleanup() {
sessionManager?.prepareForProcessTermination()
FlowLiveActivityController.endAllSynchronouslyOnTerminate()
}
}
@@ -0,0 +1,24 @@
// SpeechHistoryStore+iOS.swift
// OSGKeyboard · Main App
//
// iOS-only helper that records history and home-screen usage stats together.
import Foundation
import OSGKeyboardShared
extension SpeechHistoryStore {
/// Append history and update cumulative home-screen usage stats.
func recordUtterance(
text: String,
engineMode: String,
duration: TimeInterval,
wasTranslation: Bool
) {
append(text: text, engineMode: engineMode)
UsageStatisticsStore.shared.recordUtterance(
text: text,
duration: duration,
wasTranslation: wasTranslation
)
}
}
@@ -1,102 +0,0 @@
// SpeechHistoryStore.swift
// OSGKeyboard · Main App
//
// Local-only log of successful voice transcriptions (Flow + future paths).
import Foundation
import Combine
struct SpeechHistoryEntry: Codable, Identifiable, Equatable {
let id: UUID
let text: String
let createdAt: Date
let engineMode: String
init(id: UUID = UUID(), text: String, createdAt: Date = Date(), engineMode: String) {
self.id = id
self.text = text
self.createdAt = createdAt
self.engineMode = engineMode
}
}
@MainActor
final class SpeechHistoryStore: ObservableObject {
static let shared = SpeechHistoryStore()
@Published private(set) var entries: [SpeechHistoryEntry] = []
private let defaults: UserDefaults
private let storageKey = "speechHistory.entries.v1"
private let maxEntries = 500
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
load()
}
func append(text: String, engineMode: String) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let entry = SpeechHistoryEntry(text: trimmed, engineMode: engineMode)
entries.insert(entry, at: 0)
if entries.count > maxEntries {
entries = Array(entries.prefix(maxEntries))
}
persist()
}
/// Append history and update cumulative home-screen usage stats.
func recordUtterance(
text: String,
engineMode: String,
duration: TimeInterval,
wasTranslation: Bool
) {
append(text: text, engineMode: engineMode)
UsageStatisticsStore.shared.recordUtterance(
text: text,
duration: duration,
wasTranslation: wasTranslation
)
}
func delete(id: UUID) {
guard entries.contains(where: { $0.id == id }) else { return }
entries.removeAll { $0.id == id }
persist()
}
func clearAll() {
entries.removeAll()
persist()
}
/// Entries grouped by calendar day (newest day first).
var groupedByDay: [(day: Date, items: [SpeechHistoryEntry])] {
let calendar = Calendar.current
var buckets: [Date: [SpeechHistoryEntry]] = [:]
for entry in entries {
let day = calendar.startOfDay(for: entry.createdAt)
buckets[day, default: []].append(entry)
}
return buckets.keys.sorted(by: >).map { day in
(day, buckets[day]!.sorted { $0.createdAt > $1.createdAt })
}
}
private func load() {
guard let data = defaults.data(forKey: storageKey) else { return }
do {
entries = try JSONDecoder().decode([SpeechHistoryEntry].self, from: data)
} catch {
entries = []
}
}
private func persist() {
guard let data = try? JSONEncoder().encode(entries) else { return }
defaults.set(data, forKey: storageKey)
}
}
@@ -1,103 +0,0 @@
// UsageStatisticsStore.swift
// OSGKeyboard · Main App
//
// Cumulative usage metrics shown on the home screen stats card.
// Updated when Flow finalizes a successful utterance.
import Foundation
import Combine
import OSGKeyboardShared
struct UsageStatistics: Codable, Equatable {
var dictationDurationSeconds: TimeInterval
var dictationCharacterCount: Int
var translationCharacterCount: Int
static let zero = UsageStatistics(
dictationDurationSeconds: 0,
dictationCharacterCount: 0,
translationCharacterCount: 0
)
}
@MainActor
final class UsageStatisticsStore: ObservableObject {
static let shared = UsageStatisticsStore()
@Published private(set) var dictationDurationSeconds: TimeInterval = 0
@Published private(set) var dictationCharacterCount: Int = 0
@Published private(set) var translationCharacterCount: Int = 0
private let defaults: UserDefaults
private let storageKey = "usageStatistics.v1"
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
load()
}
func recordUtterance(text: String, duration: TimeInterval, wasTranslation: Bool) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let count = Self.characterCount(for: trimmed)
if wasTranslation {
translationCharacterCount += count
} else {
dictationCharacterCount += count
}
dictationDurationSeconds += max(0, duration)
persist()
}
static func characterCount(for text: String) -> Int {
text.trimmingCharacters(in: .whitespacesAndNewlines).count
}
// MARK: - Formatting
static func formatDuration(_ seconds: TimeInterval, language: AppUILanguage) -> String {
let total = max(0, Int(seconds.rounded()))
if total < 60 {
return language.resolvedLanguageCode().hasPrefix("zh")
? "\(total)"
: "\(total)s"
}
let hours = total / 3600
let minutes = (total % 3600) / 60
if hours > 0 {
return language.resolvedLanguageCode().hasPrefix("zh")
? "\(hours)小时\(minutes)"
: "\(hours)h \(minutes)m"
}
return language.resolvedLanguageCode().hasPrefix("zh")
? "\(minutes)"
: "\(minutes)m"
}
static func formatCount(_ value: Int, language: AppUILanguage) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.locale = Locale(identifier: language.resolvedLanguageCode())
return formatter.string(from: NSNumber(value: value)) ?? "\(value)"
}
private func load() {
guard let data = defaults.data(forKey: storageKey),
let stats = try? JSONDecoder().decode(UsageStatistics.self, from: data)
else { return }
dictationDurationSeconds = stats.dictationDurationSeconds
dictationCharacterCount = stats.dictationCharacterCount
translationCharacterCount = stats.translationCharacterCount
}
private func persist() {
let stats = UsageStatistics(
dictationDurationSeconds: dictationDurationSeconds,
dictationCharacterCount: dictationCharacterCount,
translationCharacterCount: translationCharacterCount
)
guard let data = try? JSONEncoder().encode(stats) else { return }
defaults.set(data, forKey: storageKey)
}
}
@@ -75,6 +75,12 @@ struct HomeStatsCard: View {
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
refreshDictionaryCount()
}
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
refreshDictionaryCount()
}
.onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in
stats.reloadFromDisk()
}
}
private func statCell(systemImage: String, value: String, label: LocalizedStringKey) -> some View {
+162 -83
View File
@@ -1,13 +1,29 @@
// FlowColdStartOverlay.swift
// OSGKeyboard · Main App
//
// Minimal cold-start handoff UI: bottom-bar swipe guidance and optional return link.
// Cold-start handoff hint: a bottom-anchored, full-width gradient that keeps
// the current app visible while Flow proves that voice input is actually
// ready. Failure states reuse the same minimal layout and only change the
// text permission issues are handled with a single "open Settings" link,
// never a second in-app permission flow.
import SwiftUI
import OSGKeyboardShared
struct FlowColdStartContext: Equatable {
let hostEntry: HostAppEntry?
var state: FlowColdStartState
}
enum FlowColdStartState: Equatable {
case preparing
case ready
case failed(FlowColdStartFailure)
}
enum FlowColdStartFailure: Equatable {
case permission(message: String)
case audio(message: String)
}
struct FlowColdStartOverlay: View {
@@ -16,105 +32,168 @@ struct FlowColdStartOverlay: View {
let context: FlowColdStartContext
let onReturnToHost: () -> Void
let onDismiss: () -> Void
let onRetry: () -> Void
let onOpenSettings: () -> Void
@State private var swipeOffset: CGFloat = 0
/// Fraction of the screen height the bottom gradient occupies.
private let gradientHeightFraction: CGFloat = 0.50
private let homeBarWidth: CGFloat = 134
/// Ready and failure states dismiss on blank tap; preparing stays
/// informational only (no accidental dismiss while proving audio).
private var allowsBlankTapDismiss: Bool {
switch context.state {
case .ready, .failed:
return true
case .preparing:
return false
}
}
var body: some View {
ZStack {
palette.background.opacity(0.96)
.ignoresSafeArea()
GeometryReader { geo in
ZStack(alignment: .bottom) {
// Full-width bottom gradient: transparent at the top of the
// band, nearly opaque at the bottom so hint text stays readable.
LinearGradient(
colors: [
palette.background.opacity(0.35),
palette.background.opacity(0.72),
palette.background.opacity(0.97)
],
startPoint: .top,
endPoint: .bottom
)
.frame(height: geo.size.height * gradientHeightFraction)
.frame(maxWidth: .infinity, alignment: .bottom)
.allowsHitTesting(false)
VStack(spacing: 0) {
Spacer()
VStack(spacing: Spacing.xl) {
Image("OSGBrandMark")
.renderingMode(.template)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 64, height: 64)
.foregroundStyle(palette.accent)
.accessibilityHidden(true)
Text("flow.coldStart.title")
.font(TypeStyle.title3)
.foregroundStyle(palette.textPrimary)
.multilineTextAlignment(.center)
Text("flow.coldStart.swipeHint")
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, Spacing.lg)
if context.hostEntry != nil {
Button(action: onReturnToHost) {
Text(returnButtonTitle)
.font(TypeStyle.body.weight(.semibold))
.foregroundStyle(palette.accent)
}
.buttonStyle(.plain)
}
if allowsBlankTapDismiss {
// Captures taps on empty overlay space (dismiss) and blocks
// pass-through to the host shell underneath. Action buttons in
// `content` sit above this layer and remain tappable.
Color.clear
.contentShape(Rectangle())
.onTapGesture(perform: onDismiss)
.ignoresSafeArea()
}
.padding(.horizontal, Spacing.xl)
Spacer()
VStack(spacing: Spacing.lg) {
content
.padding(.horizontal, Spacing.xl)
bottomSwipeGuide
.padding(.bottom, Spacing.md)
Text("flow.coldStart.tapToDismiss")
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.padding(.bottom, Spacing.xl)
homeIndicator
.padding(.bottom, max(geo.safeAreaInsets.bottom, Spacing.sm))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
.ignoresSafeArea()
}
.contentShape(Rectangle())
.onTapGesture(perform: onDismiss)
.animation(.easeInOut(duration: 0.2), value: context.state)
.accessibilityElement(children: .contain)
}
private var returnButtonTitle: String {
guard let entry = context.hostEntry else {
return AppL10n.string("flow.coldStart.return.generic")
@ViewBuilder
private var content: some View {
VStack(spacing: Spacing.md) {
statusIcon
Text(title)
.font(TypeStyle.title3)
.foregroundStyle(palette.textPrimary)
.multilineTextAlignment(.center)
Text(message)
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, Spacing.md)
actionLink
}
let appName = AppL10n.string(entry.displayNameKey)
return AppL10n.format("flow.coldStart.return.named", appName)
}
private var bottomSwipeGuide: some View {
VStack(spacing: Spacing.sm) {
Image(systemName: "arrow.down")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(palette.textTertiary)
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 2.5, style: .continuous)
.fill(palette.textTertiary.opacity(0.35))
.frame(width: homeBarWidth, height: 5)
Circle()
.fill(palette.accent)
.frame(width: 8, height: 8)
.offset(x: swipeOffset)
}
.frame(width: homeBarWidth, height: 16)
HStack(spacing: Spacing.xs) {
Image(systemName: "arrow.left")
.font(.system(size: 12, weight: .semibold))
Image(systemName: "arrow.right")
.font(.system(size: 12, weight: .semibold))
}
.foregroundStyle(palette.textTertiary)
@ViewBuilder
private var statusIcon: some View {
switch context.state {
case .preparing:
ProgressView()
.tint(palette.accent)
.scaleEffect(1.1)
.accessibilityLabel(AppL10n.string("flow.coldStart.preparing"))
case .ready:
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 26, weight: .semibold))
.foregroundStyle(palette.accent)
.accessibilityHidden(true)
case .failed:
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 26, weight: .semibold))
.foregroundStyle(palette.warning)
.accessibilityHidden(true)
}
.accessibilityLabel(AppL10n.string("flow.coldStart.swipeAccessibility"))
.onAppear {
swipeOffset = 0
withAnimation(.easeInOut(duration: 1.4).repeatForever(autoreverses: true)) {
swipeOffset = homeBarWidth - 8
}
@ViewBuilder
private var actionLink: some View {
switch context.state {
case .preparing, .ready:
EmptyView()
case .failed(let failure):
switch failure {
case .permission:
linkButton(AppL10n.string("flow.coldStart.action.settings"), action: onOpenSettings)
case .audio:
linkButton(AppL10n.string("flow.coldStart.action.retry"), action: onRetry)
}
}
}
private func linkButton(_ title: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(title)
.font(TypeStyle.body.weight(.semibold))
.foregroundStyle(palette.accent)
}
.buttonStyle(.plain)
}
private var title: String {
switch context.state {
case .preparing:
return AppL10n.string("flow.coldStart.preparing")
case .ready:
return AppL10n.string("flow.coldStart.title")
case .failed(let failure):
switch failure {
case .permission:
return AppL10n.string("flow.coldStart.permission.title")
case .audio:
return AppL10n.string("flow.coldStart.audio.title")
}
}
}
private var message: String {
switch context.state {
case .preparing:
return AppL10n.string("flow.coldStart.preparingHint")
case .ready:
return AppL10n.string("flow.coldStart.swipeHint")
case .failed(let failure):
switch failure {
case .permission(let message):
return message
case .audio(let message):
return message
}
}
}
/// System-style home indicator anchors the swipe-to-return gesture.
private var homeIndicator: some View {
Capsule()
.fill(palette.textTertiary.opacity(context.state == .ready ? 0.55 : 0.35))
.frame(width: 134, height: 5)
.accessibilityHidden(true)
}
}
+4
View File
@@ -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() {
+3 -1
View File
@@ -29,7 +29,9 @@ struct MainAppRoot: View {
FlowColdStartOverlay(
context: context,
onReturnToHost: { flowManager.returnToPendingHostFromColdStart() },
onDismiss: { flowManager.dismissColdStartOverlay() }
onDismiss: { flowManager.dismissColdStartOverlay() },
onRetry: { flowManager.retryColdStartReadiness() },
onOpenSettings: { flowManager.openColdStartPermissionSettings() }
)
.transition(.opacity)
}
@@ -263,7 +263,7 @@ struct PersonalDictionaryView: View {
}
private func delete(_ entry: PersonalDictionary.Entry) {
dictionary.entries.removeAll { $0.id == entry.id }
dictionary.recordDeletion(of: entry.id)
generatingAliasEntryIDs.remove(entry.id)
persist()
}
@@ -275,7 +275,7 @@ struct PersonalDictionaryView: View {
}
private func clearAll() {
dictionary = .empty
dictionary.recordClearAll()
generatingAliasEntryIDs = []
persist()
}
@@ -95,18 +95,3 @@ struct ProviderPickerSection: View {
.background(palette.accentMuted, in: Capsule())
}
}
enum ProviderLogo {
static func assetName(for providerId: String) -> String? {
switch providerId {
case "openai": return "openai"
case "deepseek": return "deepseek"
case "qwen": return "qwen"
case "moonshot": return "moonshot"
case "zhipu": return "zhipu"
case "mimo": return "mimo"
case "custom": return "custom"
default: return nil
}
}
}
+35 -3
View File
@@ -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:
+11 -3
View File
@@ -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 %@";
+11 -3
View File
@@ -368,8 +368,9 @@
"settings.personalDictionary.iCloudSync.error.generic" = "无法与 iCloud 同步词库,请稍后重试。";
"settings.iCloudSync.title" = "iCloud 同步";
"settings.appSettings.iCloudSync.title" = "设置 iCloud 同步";
"settings.appSettings.iCloudSync.subtitle" = "在多台设备间同步引擎、语言、润色和 Flow 偏好。API 密钥仍保留在各设备本地。";
"settings.appSettings.iCloudSync.title" = "iCloud 同步";
"settings.appSettings.iCloudSync.subtitle" = "通过 iCloud 在多台设备间同步设置、使用统计、语音历史与 API 密钥。API 密钥经私人 iCloud 钥匙串同步。";
"settings.appSettings.iCloudSync.syncNow" = "立即同步";
"settings.appSettings.iCloudSync.error.generic" = "无法与 iCloud 同步设置,请稍后重试。";
/* v0.3.0: 润色强度 */
@@ -390,7 +391,14 @@
/* 冷启动兜底(方案 B */
"flow.coldStart.title" = "语音已就绪";
"flow.coldStart.swipeHint" = "沿屏幕底部的横条,从左向右滑动,返回上一个 App。";
"flow.coldStart.preparing" = "正在就绪";
"flow.coldStart.preparingHint" = "请先停留片刻,我们正在启动麦克风会话。";
"flow.coldStart.permission.title" = "需要权限";
"flow.coldStart.audio.title" = "语音暂时无法启动";
"flow.coldStart.error.audioTimeout" = "麦克风未能及时就绪,可能正被通话或其他 App 占用。请稍后重试。";
"flow.coldStart.action.settings" = "前往设置";
"flow.coldStart.action.retry" = "重试";
"flow.coldStart.swipeHint" = "沿屏幕底部的横条,从左向右滑动\n返回上一个 App。";
"flow.coldStart.swipeAccessibility" = "沿底部横条从左向右滑动返回";
"flow.coldStart.tapToDismiss" = "点按屏幕关闭";
"flow.coldStart.return.named" = "返回%@";
@@ -313,6 +313,10 @@ public final class KeyboardViewController: UIInputViewController {
switch self.state.phase {
case .error:
self.state.phase = .idle
// Re-derive mic availability right away so a now-ready host
// turns the mic green immediately instead of lingering orange
// until the next monitor tick.
self.flowCoordinator.refreshSessionState()
default:
break
}
@@ -18,6 +18,7 @@ final class KeyboardConfigSync {
private var flowSessionDarwinObserver: FlowSessionDarwinObserver?
private var transcriptionDarwinObserver: FlowSessionDarwinObserver?
private var hostReadyDarwinObserver: FlowSessionDarwinObserver?
private var configDarwinObserver: FlowSessionDarwinObserver?
init(
@@ -39,6 +40,11 @@ final class KeyboardConfigSync {
) { [weak self] in
self?.onFlowSessionChanged()
}
hostReadyDarwinObserver = FlowSessionDarwinObserver(
notificationName: FlowSessionDarwin.hostReadyNotificationName
) { [weak self] in
self?.onFlowSessionChanged()
}
configDarwinObserver = FlowSessionDarwinObserver(
notificationName: AppGroupConfigDarwin.notificationName
) { [weak self] in
@@ -32,12 +32,20 @@ final class KeyboardFlowCoordinator {
private var isFlowRecording = false
private var flowWatchdogTask: Task<Void, Never>?
private var utteranceTimerTask: Task<Void, Never>?
private var hostReadyWaitTask: Task<Void, Never>?
private var utteranceStartedAt: TimeInterval = 0
private var wasFlowSessionActive = false
private var wasSessionActive = false
/// Last wall-clock time the host published a fresh ready contract. Used to
/// smooth over transient cross-process heartbeat read jitter so a single
/// stale sample never flashes the mic orange while the session is healthy.
private var lastHostReadyAt: TimeInterval = 0
private static let hostReadyGrace: TimeInterval = 4
private var flowSessionMonitorTask: Task<Void, Never>?
private var isAwaitingFlowResult = false
private var lastFlowAutoStartAttempt: TimeInterval = 0
private static let flowAutoStartCooldown: TimeInterval = 20
private var activeSessionId: UUID?
private var currentUtteranceId: UUID?
private var currentCommandSeq: Int64 = 0
private var lastAvailabilityTraceSignature = ""
init(
state: KeyboardState,
@@ -82,9 +90,11 @@ final class KeyboardFlowCoordinator {
func stopSessionMonitor() {
flowSessionMonitorTask?.cancel()
flowSessionMonitorTask = nil
stopHostReadyWait()
}
func refreshSessionState() {
FlowSessionBridge.reloadFromDisk()
refreshConfigFromAppGroup()
refreshFlowPartialIfNeeded()
consumePendingFlowDeliveryIfNeeded()
@@ -95,10 +105,26 @@ final class KeyboardFlowCoordinator {
debug("cleared zombie Flow session from App Group")
}
let reachable = FlowSessionBridge.isHostReachable()
state.flowSessionActive = reachable
// A stale "session ended" hint may linger from an earlier drop. If the
// host is provably ready again, recover to idle now so the mic can go
// green immediately instead of waiting out the auto-clear timer.
if case .error(.flowSessionExpired, _) = state.phase,
FlowSessionBridge.isHostReady() {
state.phase = .idle
state.lastTranscript = ""
}
if wasFlowSessionActive && !reachable && !isFlowRecording && !isPendingFlowStart {
recomputeMicVoiceAvailability()
startHostReadyWaitIfNeeded()
// Only surface "session ended" when the session contract *genuinely*
// dropped (expired / cleared). A transient host-ready flap engine
// hiccup or a stale cross-process read while the session is still
// valid must never nuke a healthy ready state into a sticky error,
// otherwise the error phase forces the mic orange and defeats the
// ready-wait poll until the auto-clear fires.
let sessionActive = FlowSessionBridge.isSessionActive()
if wasSessionActive && !sessionActive && !isFlowRecording && !isPendingFlowStart {
switch state.phase {
case .recording, .processing:
break
@@ -106,9 +132,81 @@ final class KeyboardFlowCoordinator {
showFlowSessionExpiredHint()
}
}
wasFlowSessionActive = reachable
wasSessionActive = sessionActive
}
maybeAutoStartFlowSession()
private func recomputeMicVoiceAvailability() {
FlowSessionBridge.reloadFromDisk()
let readySnapshot = FlowSessionBridge.readySnapshot()
activeSessionId = readySnapshot?.sessionId ?? activeSessionId
let hostReady = readySnapshot?.ready == true && FlowSessionBridge.isHostReady()
let now = Date().timeIntervalSince1970
if hostReady { lastHostReadyAt = now }
// Grace window: the host was ready very recently, so treat a momentary
// stale heartbeat read as "still warming" rather than an outright
// failure. `isSessionActive` is heartbeat-independent, so it stays true
// across cross-process read jitter and anchors this smoothing.
let withinReadyGrace = lastHostReadyAt > 0
&& (now - lastHostReadyAt) <= Self.hostReadyGrace
let hostWarming = !hostReady
&& FlowSessionBridge.isSessionActive()
&& (FlowSessionBridge.isHostReachable() || isPendingFlowStart || withinReadyGrace)
state.flowSessionActive = FlowSessionBridge.isSessionActive()
state.micVoiceAvailability = MicVoiceAvailabilityResolver.resolve(
phase: state.phase,
micDisabled: state.micDisabled,
hasFullAccess: hasFullAccess(),
appGroupAvailable: AppGroup.isAvailable,
hostReady: hostReady,
isPreparingSession: isPendingFlowStart || hostWarming
)
let signature = [
"phase=\(String(describing: state.phase))",
"availability=\(String(describing: state.micVoiceAvailability))",
hostReady ? "hostReady=1" : "hostReady=0",
state.flowSessionActive ? "sessionActive=1" : "sessionActive=0",
isPendingFlowStart ? "pending=1" : "pending=0",
isFlowRecording ? "recording=1" : "recording=0",
isAwaitingFlowResult ? "awaiting=1" : "awaiting=0",
readySnapshot?.reason.rawValue ?? "snapshot=nil"
].joined(separator: "|")
if signature != lastAvailabilityTraceSignature {
lastAvailabilityTraceSignature = signature
traceState("availability.update", extra: signature)
}
}
/// Session is live but the ready contract has not landed yet poll
/// quickly instead of sticking on "session inactive" orange.
private func startHostReadyWaitIfNeeded() {
guard !isPendingFlowStart else { return }
guard FlowSessionBridge.isSessionActive() else {
stopHostReadyWait()
return
}
guard !FlowSessionBridge.isHostReady() else {
stopHostReadyWait()
return
}
guard hostReadyWaitTask == nil else { return }
hostReadyWaitTask = Task { @MainActor [weak self] in
defer { self?.hostReadyWaitTask = nil }
for _ in 0..<20 {
guard let self, !Task.isCancelled else { return }
FlowSessionBridge.reloadFromDisk()
self.recomputeMicVoiceAvailability()
if self.state.micVoiceAvailability.isReady {
return
}
try? await Task.sleep(nanoseconds: 150_000_000)
}
}
}
private func stopHostReadyWait() {
hostReadyWaitTask?.cancel()
hostReadyWaitTask = nil
}
func toggleRecording() {
@@ -129,32 +227,34 @@ final class KeyboardFlowCoordinator {
default:
return
}
guard !state.micDisabled else { return }
guard hasFullAccess() else {
guard !isPendingFlowStart else { return }
recomputeMicVoiceAvailability()
switch state.micVoiceAvailability {
case .ready:
detectAndStoreAppContext()
startFlowRecording()
case .unavailable(.missingAPIKey):
return
case .unavailable(.noFullAccess):
let msg = ExtL10n.string("keyboard.error.fullAccessRequired")
state.phase = .error(.fullAccessRequired, message: msg)
scheduleAutoClearError()
return
}
guard AppGroup.isAvailable else {
recomputeMicVoiceAvailability()
case .unavailable(.appGroupUnavailable):
let msg = ExtL10n.string("keyboard.error.appGroupCommunication")
state.phase = .error(.appGroupUnavailable, message: msg)
scheduleAutoClearError()
return
}
detectAndStoreAppContext()
let reachable = FlowSessionBridge.isHostReachable()
debug(
"pressBegan hostReachable=\(reachable) " +
"staleness=\(FlowSessionBridge.heartbeatStaleness().map { String(format: "%.1f", $0) } ?? "nil") " +
"container=\(AppGroup.containerPathForDiagnostics)"
)
if reachable {
startFlowRecording()
} else {
recomputeMicVoiceAvailability()
case .unavailable(.preparingSession):
detectAndStoreAppContext()
beginFlowStart()
case .unavailable(.hostNotReady):
detectAndStoreAppContext()
beginFlowStart()
case .recording, .processing:
return
}
}
@@ -168,27 +268,31 @@ final class KeyboardFlowCoordinator {
isFlowRecording = false
stopUtteranceCountdown()
ExtensionScreenWakeLock.release()
FlowSessionBridge.setRecordingState(.stopped)
debug("pressEnded wrote .stopped (readback=\(FlowSessionBridge.recordingState().rawValue))")
writeCommand(.stopRecording)
debug("pressEnded wrote stop command")
state.phase = .processing
state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing")
recomputeMicVoiceAvailability()
startFlowResultWatchdog()
}
func beginFlowStart() {
guard !isPendingFlowStart else { return }
guard !isPendingFlowStart else {
traceState("beginFlowStart.ignored", extra: "reason=pendingAlreadyTrue")
return
}
isPendingFlowStart = true
isFlowRecording = false
flowStartDeadline = Date().timeIntervalSince1970 + FlowWatchdog.startTimeout
state.lastTranscript = ExtL10n.string("keyboard.flow.startingSession")
state.phase = .processing
recomputeMicVoiceAvailability()
openHostApp("startflow")
startFlowStartWatchdog()
debug("beginFlowStart")
traceState("beginFlowStart.started")
}
func handleHostAppOpenResult(path: String, success: Bool) {
debug("openHostApp path=\(path) success=\(success)")
traceState("openHostApp.result", extra: "path=\(path) success=\(success)")
guard !success else { return }
// The open genuinely failed (iOS blocked it / no Full Access). Don't
@@ -198,7 +302,9 @@ final class KeyboardFlowCoordinator {
isPendingFlowStart = false
flowStartDeadline = 0
stopFlowWatchdog()
traceState("openHostApp.failed", extra: "path=startflow cancelPending=1")
showManualOpenHint(path: "startflow")
recomputeMicVoiceAvailability()
return
}
@@ -209,44 +315,100 @@ final class KeyboardFlowCoordinator {
guard !isAwaitingFlowResult else { return }
if isFlowRecording || isPendingFlowStart {
if isFlowRecording {
FlowSessionBridge.setRecordingState(.aborted)
writeCommand(.abort)
ExtensionScreenWakeLock.release()
}
currentUtteranceId = nil
isFlowRecording = false
isPendingFlowStart = false
stopUtteranceCountdown()
stopFlowWatchdog()
state.level = 0
recomputeMicVoiceAvailability()
}
}
// MARK: - Private
private func nextCommandSeq() -> Int64 {
let millis = Int64(Date().timeIntervalSince1970 * 1_000)
currentCommandSeq = max(currentCommandSeq + 1, millis)
return currentCommandSeq
}
private func writeCommand(_ action: FlowCommand.Action) {
guard let activeSessionId, let currentUtteranceId else { return }
let command = FlowCommand(
sessionId: activeSessionId,
utteranceId: currentUtteranceId,
commandSeq: nextCommandSeq(),
action: action,
localeId: state.localeId
)
FlowSessionBridge.writeCommand(command)
debug(
"command \(action.rawValue) seq=\(command.commandSeq) " +
"utterance=\(currentUtteranceId.uuidString)"
)
}
private func consumePendingFlowDeliveryIfNeeded() {
if isAwaitingFlowResult {
if let delivery = FlowSessionBridge.consumeTranscriptionDelivery() {
if let result = matchingResult(), result.status == .final, let text = result.text, !text.isEmpty {
isAwaitingFlowResult = false
stopFlowWatchdog()
textInserter.handleFlowTranscript(delivery)
FlowSessionBridge.writeAck(
FlowAck(
sessionId: result.sessionId,
utteranceId: result.utteranceId,
commandSeq: result.commandSeq
)
)
FlowSessionBridge.clearResult()
currentUtteranceId = nil
textInserter.handleFlowTranscript(
TranscriptionDelivery(text: text, polishWarning: result.warning)
)
return
}
if let error = FlowSessionBridge.consumeTranscriptionError() {
if let result = matchingResult(), isTerminalFailure(result) {
isAwaitingFlowResult = false
stopFlowWatchdog()
FlowSessionBridge.clearResult()
currentUtteranceId = nil
let error = FlowTranscriptionError(
message: result.text ?? ExtL10n.string("keyboard.flow.resultTimeout"),
kind: result.errorKind ?? .generic
)
state.phase = .error(
.fromFlowTranscription(error),
message: error.message
)
scheduleAutoClearError()
recomputeMicVoiceAvailability()
return
}
}
if isPendingFlowStart, FlowSessionBridge.isHostReachable() {
if isPendingFlowStart, FlowSessionBridge.isHostReady() {
completeFlowStartHandoff()
}
}
private func matchingResult() -> FlowResult? {
guard let result = FlowSessionBridge.latestResult() else { return nil }
guard let activeSessionId, let currentUtteranceId else { return nil }
guard result.sessionId == activeSessionId,
result.utteranceId == currentUtteranceId else {
return nil
}
return result
}
private func isTerminalFailure(_ result: FlowResult) -> Bool {
result.status == .error || result.status == .timeout || result.status == .aborted
}
/// When the host process died mid-utterance, abort local recording / waiting
/// so the user is not stuck until the long result watchdog fires.
private func recoverFromDeadHostIfNeeded() {
@@ -256,11 +418,13 @@ final class KeyboardFlowCoordinator {
isFlowRecording = false
stopUtteranceCountdown()
ExtensionScreenWakeLock.release()
FlowSessionBridge.setRecordingState(.aborted)
writeCommand(.abort)
currentUtteranceId = nil
stopFlowWatchdog()
state.level = 0
state.phase = .idle
state.lastTranscript = ""
recomputeMicVoiceAvailability()
debug("aborted recording — host heartbeat zombie")
return
}
@@ -270,32 +434,21 @@ final class KeyboardFlowCoordinator {
}
}
/// Restores the pre-ABCD behaviour: when the keyboard appears and the host
/// is not reachable, automatically jump to the main app to start Flow.
private func maybeAutoStartFlowSession() {
guard !FlowSessionBridge.isHostReachable() else { return }
guard !isPendingFlowStart, !isFlowRecording, !isAwaitingFlowResult else { return }
guard hasFullAccess(), AppGroup.isAvailable else { return }
guard case .idle = state.phase else { return }
let now = Date().timeIntervalSince1970
guard now - lastFlowAutoStartAttempt >= Self.flowAutoStartCooldown else { return }
lastFlowAutoStartAttempt = now
beginFlowStart()
}
private func failHostDisconnected() {
traceState("hostDisconnected.fail")
isAwaitingFlowResult = false
isFlowRecording = false
isPendingFlowStart = false
stopUtteranceCountdown()
ExtensionScreenWakeLock.release()
FlowSessionBridge.setRecordingState(.aborted)
writeCommand(.abort)
currentUtteranceId = nil
stopFlowWatchdog()
state.level = 0
let message = ExtL10n.string("keyboard.flow.hostDisconnected")
state.phase = .error(.flowSessionExpired, message: message)
scheduleAutoClearError()
recomputeMicVoiceAvailability()
debug("host disconnected while awaiting Flow result")
}
@@ -303,6 +456,7 @@ final class KeyboardFlowCoordinator {
let message = ExtL10n.string("keyboard.flow.sessionExpired")
state.phase = .error(.flowSessionExpired, message: message)
scheduleAutoClearError()
recomputeMicVoiceAvailability()
}
private func showManualOpenHint(path: String) {
@@ -318,10 +472,13 @@ final class KeyboardFlowCoordinator {
}
state.phase = .error(.manualOpenRequired, message: msg)
scheduleAutoClearError()
recomputeMicVoiceAvailability()
}
private func startFlowRecording() {
guard FlowSessionBridge.isHostReachable() else {
recomputeMicVoiceAvailability()
guard state.micVoiceAvailability.isReady else {
traceState("startFlowRecording.blocked", extra: "availability=\(String(describing: state.micVoiceAvailability))")
beginFlowStart()
return
}
@@ -329,19 +486,24 @@ final class KeyboardFlowCoordinator {
flowStartDeadline = 0
stopFlowWatchdog()
FlowSessionBridge.setTranscriptionLanguage(state.localeId)
FlowSessionBridge.setRecordingState(.recording)
guard let sessionId = FlowSessionBridge.readySnapshot()?.sessionId else {
traceState("startFlowRecording.blocked", extra: "reason=missingSessionIdInReadySnapshot")
beginFlowStart()
return
}
activeSessionId = sessionId
currentUtteranceId = UUID()
writeCommand(.startRecording)
isFlowRecording = true
state.lastTranscript = ""
state.phase = .recording
recomputeMicVoiceAvailability()
if let view = wakeLockView() {
ExtensionScreenWakeLock.acquire(from: view)
}
startUtteranceCountdown()
startFlowLevelWatchdog()
// Read back in-process to confirm the write landed before we rely on
// the host polling it out cross-process.
debug("startFlowRecording wrote .recording (readback=\(FlowSessionBridge.recordingState().rawValue))")
traceState("startFlowRecording.started")
}
private func startUtteranceCountdown() {
@@ -374,13 +536,16 @@ final class KeyboardFlowCoordinator {
stopFlowWatchdog()
state.phase = .idle
state.lastTranscript = ""
recomputeMicVoiceAvailability()
traceState("pendingStart.cancelledByUser")
}
private func startFlowStartWatchdog() {
stopFlowWatchdog()
flowWatchdogTask = Task { @MainActor [weak self] in
while let self, !Task.isCancelled, self.isPendingFlowStart {
if FlowSessionBridge.isHostReachable() {
self.recomputeMicVoiceAvailability()
if FlowSessionBridge.isHostReady() {
self.completeFlowStartHandoff()
return
}
@@ -388,6 +553,7 @@ final class KeyboardFlowCoordinator {
if self.flowStartDeadline > 0, now > self.flowStartDeadline {
self.isPendingFlowStart = false
self.flowStartDeadline = 0
self.traceState("startWatchdog.timeout")
self.showManualOpenHint(path: "startflow")
return
}
@@ -403,7 +569,7 @@ final class KeyboardFlowCoordinator {
state.lastTranscript = ""
refreshSessionState()
startFlowRecording()
debug("completeFlowStartHandoff → auto startFlowRecording")
traceState("completeFlowStartHandoff.done")
}
private func startFlowLevelWatchdog() {
@@ -415,6 +581,12 @@ final class KeyboardFlowCoordinator {
self.state.level = Double(peak)
}
self.refreshFlowPartialIfNeeded()
let staleness = FlowSessionBridge.heartbeatStaleness() ?? .infinity
if staleness > 5 {
self.debug("levelWatchdog: host heartbeat stale while recording")
self.failHostDisconnected()
return
}
try? await Task.sleep(nanoseconds: FlowWatchdog.pollIntervalNs)
}
}
@@ -424,7 +596,10 @@ final class KeyboardFlowCoordinator {
guard isFlowRecording || isAwaitingFlowResult else { return }
switch state.phase {
case .recording, .processing:
if let partial = FlowSessionBridge.transcriptionPartial() {
if let result = matchingResult(),
result.status == .partial,
let partial = result.text,
!partial.isEmpty {
state.lastTranscript = partial
}
default:
@@ -440,27 +615,50 @@ final class KeyboardFlowCoordinator {
debug("resultWatchdog started timeout=\(Int(resultTimeout))s engine=\(state.engineMode)")
flowWatchdogTask = Task { @MainActor [weak self] in
while let self, !Task.isCancelled {
if let delivery = FlowSessionBridge.consumeTranscriptionDelivery() {
if let result = self.matchingResult(), result.status == .final, let text = result.text, !text.isEmpty {
self.isAwaitingFlowResult = false
self.stopFlowWatchdog()
self.debug("resultWatchdog consumed delivery len=\(delivery.text.count)")
self.textInserter.handleFlowTranscript(delivery)
FlowSessionBridge.writeAck(
FlowAck(
sessionId: result.sessionId,
utteranceId: result.utteranceId,
commandSeq: result.commandSeq
)
)
FlowSessionBridge.clearResult()
self.currentUtteranceId = nil
self.debug("resultWatchdog consumed delivery len=\(text.count)")
self.textInserter.handleFlowTranscript(
TranscriptionDelivery(text: text, polishWarning: result.warning)
)
return
}
if let error = FlowSessionBridge.consumeTranscriptionError() {
if let result = self.matchingResult(), self.isTerminalFailure(result) {
self.isAwaitingFlowResult = false
self.stopFlowWatchdog()
FlowSessionBridge.clearResult()
self.currentUtteranceId = nil
let error = FlowTranscriptionError(
message: result.text ?? ExtL10n.string("keyboard.flow.resultTimeout"),
kind: result.errorKind ?? .generic
)
self.debug("resultWatchdog consumed error kind=\(error.kind.rawValue)")
self.state.phase = .error(
.fromFlowTranscription(error),
message: error.message
)
self.scheduleAutoClearError()
self.recomputeMicVoiceAvailability()
return
}
self.refreshFlowPartialIfNeeded()
let now = Date().timeIntervalSince1970
let staleness = FlowSessionBridge.heartbeatStaleness() ?? .infinity
if self.isFlowRecording, staleness > 5 {
self.debug("level/result watchdog: host heartbeat stale while recording")
self.failHostDisconnected()
return
}
if staleness > FlowSessionKeys.heartbeatZombieInterval {
self.debug("resultWatchdog: host heartbeat zombie (staleness=\(String(format: "%.1f", staleness))s)")
self.failHostDisconnected()
@@ -475,10 +673,12 @@ final class KeyboardFlowCoordinator {
if now - startedAt > resultTimeout {
self.isAwaitingFlowResult = false
self.stopFlowWatchdog()
self.currentUtteranceId = nil
self.debug("resultWatchdog TIMEOUT after \(Int(resultTimeout))s — no result from host")
let msg = ExtL10n.string("keyboard.flow.resultTimeout")
self.state.phase = .error(.flowResultTimeout, message: msg)
self.scheduleAutoClearError()
self.recomputeMicVoiceAvailability()
return
}
try? await Task.sleep(nanoseconds: FlowWatchdog.pollIntervalNs)
@@ -494,4 +694,29 @@ final class KeyboardFlowCoordinator {
private func debug(_ message: String) {
OSGLog.keyboardExt.info("\(message, privacy: .public)")
}
private func traceState(_ event: String, extra: String? = nil) {
let staleness = FlowSessionBridge.heartbeatStaleness().map { String(format: "%.1f", $0) } ?? "nil"
let sessionId = activeSessionId?.uuidString ?? "nil"
let utteranceId = currentUtteranceId?.uuidString ?? "nil"
let summary = [
"event=\(event)",
"phase=\(String(describing: state.phase))",
"availability=\(String(describing: state.micVoiceAvailability))",
"pending=\(isPendingFlowStart)",
"recording=\(isFlowRecording)",
"awaiting=\(isAwaitingFlowResult)",
"sessionId=\(sessionId)",
"utteranceId=\(utteranceId)",
"cmdSeq=\(currentCommandSeq)",
"sessionActive=\(FlowSessionBridge.isSessionActive())",
"hostReady=\(FlowSessionBridge.isHostReady())",
"heartbeatStaleness=\(staleness)"
].joined(separator: " ")
if let extra, !extra.isEmpty {
debug("[trace] \(summary) \(extra)")
} else {
debug("[trace] \(summary)")
}
}
}
+81 -67
View File
@@ -138,8 +138,7 @@ public struct KeyboardRootView: View {
TranscriptLine(
phase: state.phase,
transcript: state.lastTranscript,
flowSessionActive: state.flowSessionActive,
micDisabled: state.micDisabled,
micVoiceAvailability: state.micVoiceAvailability,
micDisabledHint: state.micDisabledHint,
cursorDragHintActive: state.cursorDragActive,
openSettings: state.openSettings
@@ -192,7 +191,6 @@ public struct KeyboardRootView: View {
private var micActionRow: some View {
let editingBlocked = voiceInputBlocksEditing
let swapKeys = state.handednessPreference.swapsActionKeys
let micDisabled = state.micDisabled
let cursorPadsEnabled = state.cursorDragNavigationEnabled && !editingBlocked
// Dragging hides the mic + bottom keys (kept in the layout via
@@ -208,7 +206,7 @@ public struct KeyboardRootView: View {
phase: buttonPhase,
level: state.level,
remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil,
isEnabled: !micDisabled,
isEnabled: !state.micDisabled,
onToggle: state.tapMic
)
.frame(width: KeyboardLayoutMetrics.micSize, height: KeyboardLayoutMetrics.micSize)
@@ -284,13 +282,17 @@ public struct KeyboardRootView: View {
}
private var buttonPhase: RecordButton.Phase {
switch state.phase {
case .idle: return .idle
case .requestingPermissions: return .idle
case .recording: return .recording
case .processing: return .processing
case .error: return .error
case .denied: return .error
if case .error = state.phase { return .error }
if case .denied = state.phase { return .error }
switch state.micVoiceAvailability {
case .ready:
return .idleReady
case .unavailable:
return .idleUnavailable
case .recording:
return .recording
case .processing:
return .processing
}
}
}
@@ -330,8 +332,7 @@ private struct TranscriptLine: View {
let phase: KeyboardViewController.State.Phase
let transcript: String
let flowSessionActive: Bool
let micDisabled: Bool
let micVoiceAvailability: MicVoiceAvailability
let micDisabledHint: String
let cursorDragHintActive: Bool
let openSettings: () -> Void
@@ -353,66 +354,79 @@ private struct TranscriptLine: View {
private var phaseContent: some View {
switch phase {
case .idle:
if micDisabled {
Text(micDisabledHint)
.font(TypeStyle.caption)
.foregroundStyle(palette.warning)
.lineLimit(1)
.truncationMode(.tail)
} else if flowSessionActive {
ExtL10n.text("keyboard.placeholder.idle")
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
} else {
ExtL10n.text("keyboard.flow.sessionInactive")
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
}
case .requestingPermissions:
HStack(spacing: 6) {
ProgressView().controlSize(.mini).tint(palette.textSecondary)
ExtL10n.text("keyboard.placeholder.preparing")
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
case .recording:
Text(transcript.isEmpty ? " " : transcript)
.font(TypeStyle.caption)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.truncationMode(.head)
.frame(maxWidth: .infinity)
case .processing:
Text(transcript.isEmpty ? ExtL10n.string("keyboard.placeholder.processing") : transcript)
idleHint
case .requestingPermissions:
HStack(spacing: 6) {
ProgressView().controlSize(.mini).tint(palette.textSecondary)
ExtL10n.text("keyboard.placeholder.preparing")
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
.truncationMode(.tail)
case .error(_, let msg):
Text(msg ?? "")
.font(TypeStyle.caption)
.foregroundStyle(palette.warning)
.lineLimit(1)
.truncationMode(.tail)
case .denied(let reason):
Button(action: openSettings) {
HStack(spacing: 4) {
Text(deniedMessage(for: reason))
.lineLimit(1)
.truncationMode(.tail)
Image(systemName: "chevron.right")
.font(.system(size: 10, weight: .semibold))
}
.font(TypeStyle.caption)
.foregroundStyle(palette.warning)
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
}
case .recording:
Text(transcript.isEmpty ? " " : transcript)
.font(TypeStyle.caption)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.truncationMode(.head)
.frame(maxWidth: .infinity)
case .processing:
Text(transcript.isEmpty ? ExtL10n.string("keyboard.placeholder.processing") : transcript)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
.truncationMode(.tail)
case .error(_, let msg):
Text(msg ?? "")
.font(TypeStyle.caption)
.foregroundStyle(palette.warning)
.lineLimit(1)
.truncationMode(.tail)
case .denied(let reason):
Button(action: openSettings) {
HStack(spacing: 4) {
Text(deniedMessage(for: reason))
.lineLimit(1)
.truncationMode(.tail)
Image(systemName: "chevron.right")
.font(.system(size: 10, weight: .semibold))
}
.buttonStyle(.plain)
.accessibilityHint(ExtL10n.text("keyboard.deniedHint"))
.font(TypeStyle.caption)
.foregroundStyle(palette.warning)
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityHint(ExtL10n.text("keyboard.deniedHint"))
}
}
@ViewBuilder
private var idleHint: some View {
let isWarning = micVoiceAvailability.isUnavailable
Group {
switch micVoiceAvailability {
case .ready:
ExtL10n.text("keyboard.placeholder.idle")
case .unavailable(.missingAPIKey):
Text(micDisabledHint)
case .unavailable(.hostNotReady):
ExtL10n.text("keyboard.flow.sessionInactive")
case .unavailable(.preparingSession):
ExtL10n.text("keyboard.flow.startingSession")
case .unavailable(.noFullAccess):
ExtL10n.text("keyboard.error.fullAccessRequired")
case .unavailable(.appGroupUnavailable):
ExtL10n.text("keyboard.error.appGroupCommunication")
case .recording, .processing:
EmptyView()
}
}
.font(TypeStyle.caption)
.foregroundStyle(isWarning ? palette.warning : palette.textTertiary)
.lineLimit(1)
.truncationMode(.tail)
}
private func deniedMessage(for reason: KeyboardViewController.State.Phase.Reason) -> String {
switch reason {
case .mic: return ExtL10n.string("keyboard.denied.mic")
@@ -1,4 +1,4 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg width="912" height="251" viewBox="56 387 912 251" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M627.511 512.419C627.511 448.783 576.127 397.197 512.741 397.197C449.355 397.197 397.971 448.783 397.971 512.419C397.971 576.054 449.355 627.641 512.741 627.641V637.837C443.746 637.837 387.814 581.686 387.814 512.419C387.814 443.152 443.746 387 512.741 387C581.736 387 637.668 443.152 637.668 512.419C637.668 581.686 581.736 637.837 512.741 637.837V627.641C576.127 627.641 627.511 576.054 627.511 512.419Z" fill="white"/>
<path d="M56.2632 512.419C56.2632 581.686 112.195 637.837 181.19 637.837C250.185 637.837 306.117 581.686 306.117 512.419C306.117 443.152 250.185 387 181.19 387C112.195 387 56.2632 443.152 56.2632 512.419Z" fill="white"/>
<path d="M399.618 562.094L399.618 552.257L512.74 552.257L512.74 562.094L399.618 562.094Z" fill="white"/>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -17,7 +17,7 @@ struct FlowLiveActivityWidget: Widget {
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
FlowLiveActivityBrandMark(size: 28)
FlowLiveActivityBrandMark(height: 16)
}
DynamicIslandExpandedRegion(.trailing) {
FlowLiveActivityPhaseLabel(phase: context.state.phase)
@@ -32,11 +32,13 @@ struct FlowLiveActivityWidget: Widget {
.foregroundStyle(.secondary)
}
} compactLeading: {
FlowLiveActivityBrandMark(size: 22)
FlowLiveActivityBrandMark(height: 12)
} compactTrailing: {
FlowLiveActivityTrailingGlyph(phase: context.state.phase)
} minimal: {
FlowLiveActivityBrandMark(size: 18)
// The minimal slot is a tiny circle; a short wordmark keeps
// the natural ratio without overflowing its bounds.
FlowLiveActivityBrandMark(height: 6)
}
.keylineTint(Color(red: 0.35, green: 0.55, blue: 1.0))
}
@@ -50,7 +52,7 @@ private struct FlowLiveActivityLockScreenView: View {
var body: some View {
HStack(spacing: 12) {
FlowLiveActivityBrandMark(size: 32)
FlowLiveActivityBrandMark(height: 13)
VStack(alignment: .leading, spacing: 4) {
Text("OSGKeyboard")
.font(.headline)
@@ -71,13 +73,18 @@ private struct FlowLiveActivityLockScreenView: View {
/// Branded mark used in compactLeading so users see OSGKeyboard, not the system mic icon.
/// Transparent white OSG glyphs render directly on the black Dynamic Island.
private struct FlowLiveActivityBrandMark: View {
let size: CGFloat
/// The OSG wordmark is wide and short; pin the width to its true aspect
/// ratio so it never collapses into a thin sliver inside a square frame.
private static let aspectRatio: CGFloat = 912.0 / 251.0
/// Rendered glyph height; width follows the wordmark's natural ratio.
let height: CGFloat
var body: some View {
Image("OSGLogo")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: size, height: size)
.frame(width: height * Self.aspectRatio, height: height)
.accessibilityLabel("OSGKeyboard")
}
}
@@ -92,9 +99,10 @@ private struct FlowLiveActivityTrailingGlyph: View {
.foregroundStyle(.red)
.symbolEffect(.variableColor.iterative, options: .repeating)
case .processing:
ProgressView()
.progressViewStyle(.circular)
.tint(.white)
Image(systemName: "ellipsis")
.font(.title3.weight(.semibold))
.foregroundStyle(.white)
.symbolEffect(.variableColor.iterative, options: .repeating)
case .idle:
// Session ready but NOT listening avoid a mic glyph so users
// don't think the keyboard is recording in the background.
+263
View File
@@ -0,0 +1,263 @@
// DashboardView.swift
// OSGKeyboard · Mac
//
// Primary workspace: session stats, dictation canvas, floating record bar.
import SwiftUI
struct DashboardView: View {
@ObservedObject var viewModel: MacDictationViewModel
@ObservedObject private var stats: UsageStatisticsStore
@Environment(\.themePalette) private var palette
private var lang: AppUILanguage { viewModel.config.uiLanguage }
init(viewModel: MacDictationViewModel) {
self.viewModel = viewModel
// Observe through the view model's store instance a bare
// `UsageStatisticsStore.shared` on @ObservedObject often misses
// post-sync @Published updates on macOS.
self._stats = ObservedObject(wrappedValue: viewModel.usageStatistics)
}
// Four equal-width columns same metrics as the iOS home stats card.
private let columns = Array(
repeating: GridItem(.flexible(minimum: 120), spacing: Spacing.md),
count: 4
)
var body: some View {
VStack(spacing: 0) {
ScrollView {
VStack(alignment: .leading, spacing: Spacing.lg) {
if let appName = viewModel.foregroundAppName {
Text(MacL10n.format("mac.foregroundApp", language: lang, appName))
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.transition(.opacity)
}
statGrid
dictationCanvas
}
.animation(Motion.soft, value: viewModel.foregroundAppName)
.padding(.horizontal, Spacing.lg)
.padding(.top, Spacing.sm)
.padding(.bottom, Spacing.lg)
}
BottomDictationBar(viewModel: viewModel)
.padding(.horizontal, Spacing.lg)
.padding(.bottom, Spacing.sm)
}
.onAppear { stats.reloadFromDisk() }
.onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in
stats.reloadFromDisk()
}
}
private var statGrid: some View {
LazyVGrid(columns: columns, spacing: Spacing.md) {
StatCard(
title: MacL10n.string("mac.stat.dictationTime", language: lang),
value: UsageStatisticsStore.formatDuration(
stats.dictationDurationSeconds,
language: lang
),
caption: MacL10n.string("mac.stat.cumulativeDuration", language: lang),
systemImage: "waveform",
accent: true
)
StatCard(
title: MacL10n.string("mac.stat.words", language: lang),
value: UsageStatisticsStore.formatCount(
stats.dictationCharacterCount,
language: lang
),
caption: MacL10n.string("mac.stat.transcribed", language: lang),
systemImage: "text.alignleft"
)
StatCard(
title: MacL10n.string("mac.stat.translation", language: lang),
value: UsageStatisticsStore.formatCount(
stats.translationCharacterCount,
language: lang
),
caption: MacL10n.string("mac.stat.cumulativeTranslation", language: lang),
systemImage: "character.bubble"
)
StatCard(
title: MacL10n.string("mac.stat.dictionary", language: lang),
value: "\(viewModel.dictionaryTermCount)",
caption: MacL10n.string("mac.stat.customTerms", language: lang),
systemImage: "character.book.closed"
)
}
}
private var dictationCanvas: some View {
MacCard(padding: Spacing.lg) {
ZStack(alignment: .topLeading) {
if viewModel.transcript.isEmpty {
Text(
viewModel.isRecording
? MacL10n.string("mac.status.listening", language: lang)
: MacL10n.string("mac.status.ready", language: lang)
)
.font(.system(size: 26, weight: .light))
.foregroundStyle(palette.textTertiary)
.contentTransition(.opacity)
.frame(maxWidth: .infinity, minHeight: 220, alignment: .topLeading)
.transition(.opacity)
} else {
Text(viewModel.transcript)
.font(.system(size: 22, weight: .regular))
.foregroundStyle(palette.textPrimary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, minHeight: 220, alignment: .topLeading)
.transition(.opacity)
}
}
}
.animation(Motion.soft, value: viewModel.transcript.isEmpty)
.animation(Motion.quick, value: viewModel.isRecording)
}
}
// MARK: - Floating record bar
struct BottomDictationBar: View {
@ObservedObject var viewModel: MacDictationViewModel
@Environment(\.themePalette) private var palette
@State private var pulse = false
private var lang: AppUILanguage { viewModel.config.uiLanguage }
var body: some View {
ZStack {
HStack {
translationPicker
Spacer()
readinessChip
}
recordControl
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.macGlassSurface(in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous), fillOpacity: 1)
.overlay(
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.dividerStrong, lineWidth: 0.5)
)
}
private var readinessChip: some View {
HStack(spacing: 6) {
Circle()
.fill(viewModel.isProcessing ? palette.warning : palette.accent)
.frame(width: 7, height: 7)
Text(
viewModel.isProcessing
? MacL10n.string("mac.status.chipProcessing", language: lang)
: MacL10n.string("mac.status.chipReady", language: lang)
)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.contentTransition(.opacity)
}
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 5)
.background(palette.surfaceElevated, in: Capsule())
.animation(Motion.quick, value: viewModel.isProcessing)
}
private var translationPicker: some View {
Menu {
ForEach(TranslationLanguageCatalog.all) { language in
Button(translationLabel(for: language)) {
viewModel.config.translationTargetLocaleId = language.id
}
}
} label: {
HStack(spacing: 6) {
Image(systemName: "translate")
Text(currentTranslationLabel)
.lineLimit(1)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 9, weight: .semibold))
}
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 7)
.macGlassSurface(in: Capsule(), fillOpacity: 0.66)
}
.menuStyle(.borderlessButton)
.fixedSize()
}
//
//
// Option
private var recordControl: some View {
recordButton
.overlay(alignment: .top) {
if viewModel.isRecording {
Text(MacL10n.string("mac.record.pressStop", language: lang))
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.fixedSize()
.offset(y: -22)
.transition(.opacity.combined(with: .offset(y: 6)))
}
}
.animation(Motion.quick, value: viewModel.isRecording)
}
private var recordButton: some View {
Button(action: viewModel.toggleRecording) {
ZStack {
Circle()
.fill(viewModel.isRecording ? palette.recordRed : palette.accent)
.frame(width: 52, height: 52)
.shadow(
color: (viewModel.isRecording ? palette.recordRed : palette.accent).opacity(0.35),
radius: pulse ? 10 : 5
)
Group {
if viewModel.isRecording {
// iOS
MiniWaveform(level: viewModel.audioLevel, barCount: 4, tint: palette.textOnAccent)
} else {
Image(systemName: "mic.fill")
.font(.system(size: 20, weight: .bold))
.foregroundStyle(palette.textOnAccent)
}
}
.transition(.opacity.combined(with: .scale(scale: 0.7)))
}
}
.buttonStyle(.plain)
.disabled(viewModel.isProcessing)
.opacity(viewModel.isProcessing ? 0.55 : 1)
.scaleEffect(viewModel.isRecording ? 1.06 : 1)
.animation(Motion.soft, value: viewModel.isRecording)
.animation(Motion.quick, value: viewModel.isProcessing)
.onAppear {
withAnimation(Motion.breath) {
pulse = true
}
}
}
private var currentTranslationLabel: String {
let current = TranslationLanguageCatalog.resolve(viewModel.config.translationTargetLocaleId)
return translationLabel(for: current)
}
private func translationLabel(for language: TranslationLanguage) -> String {
if TranslationLanguageCatalog.isOff(language.id) {
return MacL10n.string("keyboard.translation.offMenu", language: lang)
}
return language.nativeName
}
}
+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>OSGKeyboard</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>zh-Hans</string>
</array>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>NSHumanReadableCopyright</key>
<string>OSGKeyboard</string>
<key>NSMicrophoneUsageDescription</key>
<string>OSGKeyboard uses the microphone to transcribe your voice via your configured cloud speech provider.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>OSGKeyboard uses on-device speech recognition for local dictation mode.</string>
</dict>
</plist>
+86
View File
@@ -0,0 +1,86 @@
// MacAppContextService.swift
// OSGKeyboard · Mac
//
// Unlike the iOS keyboard extension, macOS can read the frontmost app's
// bundle ID via NSWorkspace and map it to `AppContext` for polish prompts.
import AppKit
import Foundation
enum MacAppContextService {
/// Bundle IDs coarse polish context (macOS + cross-platform).
private static let contextByBundleId: [String: AppContext] = [
// Code / dev
"com.apple.dt.Xcode": .code,
"com.microsoft.VSCode": .code,
"com.google.android.studio": .code,
"com.jetbrains.intellij": .code,
"com.jetbrains.AppCode": .code,
"com.sublimetext.4": .code,
"com.github.GitHubClient": .code,
"com.apple.Terminal": .code,
"com.googlecode.iterm2": .code,
"dev.warp.Warp-Stable": .code,
// Email
"com.apple.mail": .email,
"com.microsoft.Outlook": .email,
"com.google.Gmail": .email,
"com.readdle.smartemail": .email,
// Chat / IM
"com.tencent.xinWeChat": .chat,
"com.tencent.qq": .chat,
"com.tencent.wework": .chat,
"com.tinyspeck.slackmacgap": .chat,
"com.hnc.Discord": .chat,
"com.microsoft.teams": .chat,
"com.microsoft.teams2": .chat,
"ru.keepcoder.Telegram": .chat,
"net.whatsapp.WhatsApp": .chat,
"com.apple.MobileSMS": .chat,
"com.facebook.archon": .chat,
"com.laiwang.DingTalk": .chat,
"com.bytedance.feishu": .chat,
// Documents / notes
"com.apple.Notes": .document,
"com.apple.iWork.Pages": .document,
"notion.id": .document,
"md.obsidian": .document,
"net.shinyfrog.bear": .document,
"com.agiletortoise.Drafts-OSX": .document,
"com.microsoft.Word": .document,
"com.google.GoogleDocs": .document,
"com.evernote.Evernote": .document,
"com.microsoft.onenote.mac": .document,
]
/// Chat-style apps from the shared host registry (iOS bundle IDs often
/// match Mac counterparts for cross-platform IM).
private static let chatBundleIdsFromRegistry: Set<String> = {
Set(HostAppURLRegistry.entries.map(\.bundleId))
}()
static func frontmostApplicationName() -> String? {
NSWorkspace.shared.frontmostApplication?.localizedName
}
static func frontmostBundleIdentifier() -> String? {
NSWorkspace.shared.frontmostApplication?.bundleIdentifier
}
static func detectContext() -> AppContext {
guard let bundleId = frontmostBundleIdentifier() else { return .unknown }
if let mapped = contextByBundleId[bundleId] { return mapped }
if chatBundleIdsFromRegistry.contains(bundleId) { return .chat }
if bundleId.hasPrefix("com.apple.Safari") || bundleId.contains("chrome") {
return .document
}
return .unknown
}
/// Persist detected context into the shared configuration store so
/// `PolishingService` reads the same signal as on iOS.
static func captureAndPersist(to store: AppGroupStore) {
let context = detectContext()
store.setDetectedAppContext(context)
}
}
+75
View File
@@ -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 }
}
}
}
+112
View File
@@ -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 (01), 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 01 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
}
}
}
+235
View File
@@ -0,0 +1,235 @@
// MacComponents.swift
// OSGKeyboard · Mac
//
// Reusable macOS UI pieces styled with the shared design tokens and the
// system-native palette (see MacTheme.swift). Kept as plain SwiftUI (no
// AppKit) so they can be reused on iPadOS. Settings / History / Dictionary
// now use the native grouped `Form`, so the old hand-rolled card/row types
// were removed what remains here is used by the Dashboard, the status
// footer and the menu-bar popover.
import SwiftUI
// MARK: - Shared layout metrics
/// Fixed metrics that keep every desktop surface on the same grid.
enum MacMetrics {
/// Uniform max width for trailing text controls (API key / model field).
static let controlWidth: CGFloat = 240
/// Sidebar width and the horizontal inset shared by brand, nav and footer.
static let sidebarWidth: CGFloat = 240
/// Horizontal inset for sidebar chrome (nav rows, footer). The brand logo
/// adds `Spacing.sm` on top of this so its left edge lines up with the
/// SF Symbol in each nav `Label`.
static let sidebarInset: CGFloat = Spacing.md
static let sidebarContentInset: CGFloat = sidebarInset + Spacing.sm
/// Reading width for single-column content.
static let contentMaxWidth: CGFloat = 720
/// Top inset that clears the window traffic-light buttons now that the
/// title bar is hidden.
static let trafficLightInset: CGFloat = 28
}
// MARK: - Liquid Glass
private struct MacGlassSurface<S: Shape>: ViewModifier {
@Environment(\.themePalette) private var palette
let shape: S
let fillOpacity: Double
func body(content: Content) -> some View {
// Flat, shadowless surface fill. We deliberately avoid `glassEffect`
// here: on macOS 26 Liquid Glass adds a raised drop shadow to every
// card, which reads as visual noise for content containers. Hierarchy
// is carried by the surface colour + hairline border instead.
content
.background(palette.surface.opacity(fillOpacity), in: shape)
}
}
extension View {
/// Applies a flat semantic surface fill (no drop shadow) behind `content`.
func macGlassSurface<S: Shape>(
in shape: S,
fillOpacity: Double = 0.72
) -> some View {
modifier(MacGlassSurface(shape: shape, fillOpacity: fillOpacity))
}
}
// MARK: - Text field styling
/// Text-field chrome aligned with native macOS Form controls: compact
/// height, text-background fill, hairline border.
private struct MacFieldStyleModifier: ViewModifier {
@Environment(\.themePalette) private var palette
func body(content: Content) -> some View {
let shape = RoundedRectangle(cornerRadius: 6, style: .continuous)
content
.textFieldStyle(.plain)
.font(TypeStyle.footnote)
.padding(.horizontal, 8)
.frame(height: 22)
.background(Color(nsColor: .textBackgroundColor), in: shape)
.overlay(shape.stroke(palette.divider, lineWidth: 0.5))
}
}
extension View {
/// Theme-aware text-field styling for the settings inputs.
func macFieldStyle() -> some View { modifier(MacFieldStyleModifier()) }
}
// MARK: - Card container
/// Elevated surface used for stat tiles and the dictation canvas.
struct MacCard<Content: View>: View {
@Environment(\.themePalette) private var palette
var padding: CGFloat = Spacing.md
@ViewBuilder var content: () -> Content
var body: some View {
let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
content()
.padding(padding)
.macGlassSurface(in: shape, fillOpacity: 1)
.overlay(
shape
.stroke(palette.divider, lineWidth: 0.5)
)
}
}
// MARK: - Stat tile
struct StatCard: View {
@Environment(\.themePalette) private var palette
let title: String
let value: String
let caption: String
var systemImage: String?
var accent: Bool = false
var body: some View {
MacCard {
VStack(alignment: .leading, spacing: Spacing.xs) {
HStack {
Text(title.uppercased())
.font(TypeStyle.caption2)
.tracking(0.6)
.foregroundStyle(palette.textTertiary)
Spacer()
if let systemImage {
Image(systemName: systemImage)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(accent ? palette.accent : palette.textTertiary)
}
}
Text(value)
.font(TypeStyle.title2)
.foregroundStyle(accent ? palette.accent : palette.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.7)
.contentTransition(.numericText())
.animation(Motion.soft, value: value)
Text(caption)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
// MARK: - Live waveform
/// Compact bar visualiser reacting to the input level while recording.
struct MiniWaveform: View {
@Environment(\.themePalette) private var palette
let level: Float
var barCount: Int = 5
/// Pass nil to inherit the palette accent automatically.
var tint: Color?
@State private var phase: CGFloat = 0
var body: some View {
HStack(spacing: 3) {
ForEach(0..<barCount, id: \.self) { index in
Capsule()
.fill(tint ?? palette.accent)
.frame(width: 3, height: barHeight(index))
}
}
.frame(height: 22)
.animation(Motion.instant, value: level)
.onAppear {
withAnimation(.linear(duration: 0.9).repeatForever(autoreverses: true)) {
phase = 1
}
}
}
private func barHeight(_ index: Int) -> CGFloat {
let base = CGFloat(level) * 22
let wobble = sin((phase * .pi * 2) + CGFloat(index)) * 4 + 4
return max(4, min(22, base * (0.6 + CGFloat(index % 2) * 0.4) + wobble))
}
}
// MARK: - Translation display helper
/// Shared label logic for the translation control so the dashboard chip,
/// the status footer and the menu-bar popover all read identically.
enum MacTranslationDisplay {
static func label(for targetLocaleId: String, language: AppUILanguage) -> String {
let resolved = TranslationLanguageCatalog.resolve(targetLocaleId)
if TranslationLanguageCatalog.isOff(resolved.id) {
return MacL10n.string("keyboard.translation.offMenu", language: language)
}
return resolved.nativeName
}
}
// MARK: - Status footer
/// Bottom status strip: engine mode (cloud/local), translation target, and
/// the connection state icons and wording mirror the dashboard record bar.
struct MacStatusFooter: View {
@ObservedObject var viewModel: MacDictationViewModel
@Environment(\.themePalette) private var palette
private var lang: AppUILanguage { viewModel.config.uiLanguage }
var body: some View {
HStack(spacing: Spacing.md) {
Spacer()
Label(
viewModel.isCloudMode
? MacL10n.string("mac.mode.cloud", language: lang)
: MacL10n.string("mac.mode.local", language: lang),
systemImage: viewModel.isCloudMode ? "cloud" : "cpu"
)
.foregroundStyle(palette.textSecondary)
.contentTransition(.opacity)
Label(
MacTranslationDisplay.label(for: viewModel.config.translationTargetLocaleId, language: lang),
systemImage: "translate"
)
.foregroundStyle(palette.textSecondary)
.contentTransition(.opacity)
Label(MacL10n.string("mac.connected", language: lang), systemImage: "link")
.foregroundStyle(palette.accent)
}
.font(TypeStyle.caption)
.labelStyle(.titleAndIcon)
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.xs)
.animation(Motion.quick, value: viewModel.isCloudMode)
.animation(Motion.quick, value: viewModel.config.translationTargetLocaleId)
}
}
+156
View File
@@ -0,0 +1,156 @@
// MacContentView.swift
// OSGKeyboard · Mac
//
// Compact menu-bar popover for quick dictation.
import SwiftUI
struct MacContentView: View {
@ObservedObject var viewModel: MacDictationViewModel
@Environment(\.themePalette) private var palette
private var lang: AppUILanguage { viewModel.config.uiLanguage }
var body: some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
header
recordButton
Text(MacL10n.string("mac.hint.holdOption", language: lang))
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .center)
Text(viewModel.statusMessage)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.contentTransition(.opacity)
if !viewModel.transcript.isEmpty {
ScrollView {
Text(viewModel.transcript)
.font(TypeStyle.footnote)
.foregroundStyle(palette.textPrimary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: 120)
.padding(Spacing.xs)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.small, style: .continuous))
.transition(.opacity.combined(with: .move(edge: .top)))
}
Divider().overlay(palette.divider)
statusRow
Divider().overlay(palette.divider)
footer
}
.padding(Spacing.md)
.background(palette.background)
.animation(Motion.soft, value: viewModel.transcript.isEmpty)
.animation(Motion.quick, value: viewModel.statusMessage)
}
private var statusRow: some View {
HStack(spacing: Spacing.sm) {
Label(
viewModel.isCloudMode
? MacL10n.string("mac.mode.cloud", language: lang)
: MacL10n.string("mac.mode.local", language: lang),
systemImage: viewModel.isCloudMode ? "cloud" : "cpu"
)
.foregroundStyle(palette.textSecondary)
Spacer()
translationMenu
Spacer()
Label(MacL10n.string("mac.connected", language: lang), systemImage: "link")
.foregroundStyle(palette.accent)
}
.font(TypeStyle.caption)
.labelStyle(.titleAndIcon)
}
private var translationMenu: some View {
Menu {
ForEach(TranslationLanguageCatalog.all) { language in
Button(MacTranslationDisplay.label(for: language.id, language: lang)) {
viewModel.config.translationTargetLocaleId = language.id
}
}
} label: {
HStack(spacing: 4) {
Image(systemName: "translate")
Text(MacTranslationDisplay.label(for: viewModel.config.translationTargetLocaleId, language: lang))
.lineLimit(1)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 8, weight: .semibold))
}
.foregroundStyle(palette.textSecondary)
}
.menuStyle(.borderlessButton)
.fixedSize()
}
private var header: some View {
HStack(spacing: Spacing.xs) {
Image("OSGBrandMark")
.renderingMode(.template)
.resizable()
.scaledToFit()
.frame(width: 16, height: 16)
.foregroundStyle(palette.accent)
Text("OSGKeyboard")
.font(TypeStyle.bodyEmph)
.foregroundStyle(palette.textPrimary)
Spacer()
if viewModel.isRecording {
MiniWaveform(level: viewModel.audioLevel, barCount: 4)
.transition(.opacity.combined(with: .scale(scale: 0.7)))
}
}
.animation(Motion.quick, value: viewModel.isRecording)
}
private var recordButton: some View {
Button(action: viewModel.toggleRecording) {
HStack {
Image(systemName: viewModel.isRecording ? "stop.fill" : "mic.fill")
.contentTransition(.symbolEffect(.replace))
Text(
viewModel.isRecording
? MacL10n.string("mac.record.stop", language: lang)
: MacL10n.string("mac.record.start", language: lang)
)
.font(TypeStyle.bodyEmph)
.contentTransition(.opacity)
}
.frame(maxWidth: .infinity, minHeight: 40)
.background(
(viewModel.isRecording ? palette.recordRed : palette.accent),
in: RoundedRectangle(cornerRadius: Radius.small, style: .continuous)
)
.foregroundStyle(palette.textOnAccent)
}
.buttonStyle(.plain)
.disabled(viewModel.isProcessing)
.opacity(viewModel.isProcessing ? 0.55 : 1)
.animation(Motion.soft, value: viewModel.isRecording)
.animation(Motion.quick, value: viewModel.isProcessing)
}
private var footer: some View {
HStack {
Button(MacL10n.string("mac.openWindow", language: lang)) { MacMainWindow.open() }
.buttonStyle(.plain)
.font(TypeStyle.caption)
.foregroundStyle(palette.accent)
Spacer()
Button(MacL10n.string("mac.quit", language: lang)) { NSApplication.shared.terminate(nil) }
.buttonStyle(.plain)
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
}
}
}
+104
View File
@@ -0,0 +1,104 @@
// MacDictationPipeline.swift
// OSGKeyboard · Mac
//
// Dictation pipeline: samples ASR (cloud or local) polish.
// Cloud path reuses `CloudASRClientFactory`; local path uses Qwen3-ASR (MLX)
// with Apple Speech fallback when weights are missing.
import Foundation
enum MacDictationError: Error, LocalizedError {
case noAudio
case providerHasNoCloudASR
case emptyTranscript
var errorDescription: String? {
switch self {
case .noAudio:
return MacL10n.string("mac.error.noAudio")
case .providerHasNoCloudASR:
return MacL10n.string("mac.error.noCloudASR")
case .emptyTranscript:
return MacL10n.string("mac.error.emptyTranscript")
}
}
}
enum MacDictationPipeline {
/// Runs ASR then best-effort polish. Polish failures fall back to raw text.
static func run(samples: [Float], store: AppGroupStore) async throws -> String {
guard !samples.isEmpty else { throw MacDictationError.noAudio }
let locale = Locale(identifier: store.localeId.isEmpty ? "zh-CN" : store.localeId)
let raw: String
var localBias: LocalASRBiasPayload?
if store.engineMode == "local" {
MacAppContextService.captureAndPersist(to: store)
let capabilities = MacLocalASRService.currentCapabilities()
let bias = LocalASRBiasAdapter.adapt(
LocalASRBiasRequest(
dictionary: store.personalDictionary,
locale: locale,
frontAppBundleId: MacAppContextService.frontmostBundleIdentifier(),
capabilities: capabilities
)
)
localBias = bias
LocalASRBiasDiagnosticsStore.save(
payload: bias,
modelId: MacLocalASRService.selectedModelDefinition()?.id,
backendLabel: MacLocalASRService.currentBackendLabel()
)
raw = try await MacLocalASRService.transcribe(
samples: samples,
locale: locale,
bias: bias
)
} else {
let strategy = CloudASRModelCatalog.strategy(for: store.providerId)
guard strategy != .localFallback else { throw MacDictationError.providerHasNoCloudASR }
let client = CloudASRClientFactory.make(store: store)
try? await client.prepare(dictionary: store.personalDictionary)
raw = try await client.transcribe(
samples: samples,
sampleRate: 16_000,
locale: locale,
dictionary: store.personalDictionary
)
}
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw MacDictationError.emptyTranscript }
let postASR: String
if let localBias, !localBias.correctionPairs.isEmpty {
postASR = LocalASRTranscriptCorrector.apply(trimmed, pairs: localBias.correctionPairs)
} else {
postASR = trimmed
}
let polishContext: PolishContext?
if let supplement = localBias?.polishFragment.trimmingCharacters(in: .whitespacesAndNewlines),
!supplement.isEmpty {
polishContext = PolishContext(
appContext: store.detectedAppContext?.context ?? .unknown,
intensity: store.polishIntensity,
dictionarySupplement: supplement
)
} else {
polishContext = nil
}
if let polished = try? await PolishingService(store: store).polish(
postASR,
mode: store.polishModeForPipeline,
context: polishContext
),
!polished.isEmpty {
return polished
}
return postASR
}
}
+310
View File
@@ -0,0 +1,310 @@
// MacDictationViewModel.swift
// OSGKeyboard · Mac
//
// Drives the whole macOS window: navigation, recording, hotkey, iCloud-backed
// settings, foreground-app context, and text insertion.
import AppKit
import Combine
import SwiftUI
/// Top-level navigation destinations, mirroring the iOS app's tabs.
enum MacSection: String, CaseIterable, Identifiable {
case dashboard
case history
case dictionary
case settings
var id: String { rawValue }
func title(language: AppUILanguage) -> String {
switch self {
case .dashboard: return MacL10n.string("mac.section.dashboard", language: language)
case .history: return MacL10n.string("mac.section.history", language: language)
case .dictionary: return MacL10n.string("mac.section.dictionary", language: language)
case .settings: return MacL10n.string("mac.section.settings", language: language)
}
}
var systemImage: String {
switch self {
case .dashboard: return "square.grid.2x2"
case .history: return "clock.arrow.circlepath"
case .dictionary: return "character.book.closed"
case .settings: return "gearshape"
}
}
}
@MainActor
final class MacDictationViewModel: ObservableObject {
/// Shared instance so the SwiftUI window and the AppKit menu-bar popover
/// (see `MacAppDelegate`) drive the exact same recording / settings state.
static let shared = MacDictationViewModel()
@Published var selectedSection: MacSection = .dashboard
@Published var isRecording = false
@Published var isProcessing = false
@Published var transcript = ""
@Published var statusMessage = ""
@Published var audioLevel: Float = 0
@Published var sessionSeconds: Int = 0
@Published var foregroundAppName: String?
@Published var dictionaryRevision = 0
@Published var autoPasteEnabled: Bool
@Published var hotkeyEnabled: Bool
@Published var config: ProviderConfig
let defaults: UserDefaults
private let recorder = MacAudioRecorder()
private let hotkeyService = MacHotkeyService()
private var levelTimer: Timer?
private var sessionTimer: Timer?
private var cancellables = Set<AnyCancellable>()
let usageStatistics: UsageStatisticsStore
let speechHistory = SpeechHistoryStore.shared
private enum StoredKeys {
static let autoPaste = "mac.autoPasteEnabled"
static let hotkey = "mac.hotkeyEnabled"
}
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
self.config = ProviderConfig(defaults: defaults)
self.usageStatistics = UsageStatisticsStore(defaults: defaults)
self.autoPasteEnabled = defaults.object(forKey: StoredKeys.autoPaste) as? Bool ?? true
self.hotkeyEnabled = defaults.object(forKey: StoredKeys.hotkey) as? Bool ?? true
MacICloudSyncBootstrap.configure(defaults: defaults)
statusMessage = MacL10n.string("mac.status.ready", language: config.uiLanguage)
wireHotkeyService()
forwardNestedObjectChanges()
}
/// `config` is a nested `ObservableObject`; without forwarding its
/// `objectWillChange`, SwiftUI views that observe only the view model
/// won't refresh when settings (e.g. engine mode / provider) change.
private func forwardNestedObjectChanges() {
config.objectWillChange
.sink { [weak self] in self?.objectWillChange.send() }
.store(in: &cancellables)
usageStatistics.objectWillChange
.sink { [weak self] in self?.objectWillChange.send() }
.store(in: &cancellables)
}
func onAppear() async {
await MacICloudSyncBootstrap.pullIfEnabled()
refreshForegroundAppName()
}
func reloadConfigFromCloud() {
config.reloadFromPersistedStorage()
statusMessage = MacL10n.string("mac.status.ready", language: config.uiLanguage)
}
func refreshDictionaryFromCloud() {
dictionaryRevision += 1
}
// MARK: - Derived
var selectableProviders: [LLMProvider] {
LLMProvider.presets.filter {
$0.isUserSelectable && $0.cloudASRStrategy != .localFallback
}
}
var dictionaryTermCount: Int {
_ = dictionaryRevision
return AppGroupStore(defaults: defaults).personalDictionary.entries.count
}
var currentWordCount: Int {
transcript.split { $0 == " " || $0 == "\n" || $0 == "\t" }.count
}
var isCloudMode: Bool { config.engineMode == "cloud" }
var languageLabel: String {
let id = config.localeId.isEmpty ? "zh-CN" : config.localeId
return Locale.current.localizedString(forIdentifier: id) ?? id
}
var sessionTimeLabel: String {
let minutes = sessionSeconds / 60
let seconds = sessionSeconds % 60
if minutes > 0 { return "\(minutes)m \(seconds)s" }
return "\(seconds)s"
}
var localModelReady: Bool {
_ = localModelRevision
guard let model = MacLocalASRService.selectedModelDefinition() else { return false }
return MacLocalASRService.isModelInstalled(model)
}
/// Context-aware warning when local engine is selected but the active model is not ready.
var localModelWarningMessage: String? {
_ = localModelRevision
guard config.engineMode == "local" else { return nil }
if localModelReady { return nil }
guard let model = MacLocalASRService.selectedModelDefinition() else {
return MacL10n.string("mac.settings.localModelFallbackApple", language: config.uiLanguage)
}
return MacL10n.format(
"mac.settings.selectedModelMissing",
language: config.uiLanguage,
model.displayName
)
}
@Published private(set) var localModelRevision = 0
func bumpLocalModelRevision() {
localModelRevision += 1
objectWillChange.send()
}
// MARK: - Preferences
func setAutoPasteEnabled(_ enabled: Bool) {
autoPasteEnabled = enabled
defaults.set(enabled, forKey: StoredKeys.autoPaste)
}
func setHotkeyEnabled(_ enabled: Bool) {
hotkeyEnabled = enabled
defaults.set(enabled, forKey: StoredKeys.hotkey)
hotkeyService.setEnabled(enabled)
if enabled { hotkeyService.start() } else { hotkeyService.stop() }
}
func setEngineMode(_ mode: String) {
config.engineMode = mode
}
// MARK: - Recording
func toggleRecording() {
if isRecording { finishRecording() } else { beginRecording() }
}
func beginRecording() {
guard !isProcessing else { return }
let store = AppGroupStore(defaults: defaults)
MacAppContextService.captureAndPersist(to: store)
refreshForegroundAppName()
do {
try recorder.start()
isRecording = true
transcript = ""
statusMessage = MacL10n.string("mac.status.listening", language: config.uiLanguage)
startTimers()
} catch {
statusMessage = error.localizedDescription
}
}
func finishRecording() {
guard isRecording else { return }
isRecording = false
isProcessing = true
statusMessage = MacL10n.string("mac.status.transcribing", language: config.uiLanguage)
stopTimers()
audioLevel = 0
let samples = recorder.stop()
let store = AppGroupStore(defaults: defaults)
Task { [weak self] in
guard let self else { return }
do {
let text = try await MacDictationPipeline.run(samples: samples, store: store)
self.transcript = text
let pasted = try self.deliver(text)
self.recordUsage(for: text)
self.speechHistory.append(text: text)
self.statusMessage = self.statusAfterDelivery(pasted: pasted)
} catch {
self.statusMessage = error.localizedDescription
}
self.isProcessing = false
}
}
private func deliver(_ text: String) throws -> Bool {
try MacTextInsertionService.insert(text, autoPaste: autoPasteEnabled)
}
private func statusAfterDelivery(pasted: Bool) -> String {
let lang = config.uiLanguage
if autoPasteEnabled, pasted {
return MacL10n.string("mac.status.copiedAndPasted", language: lang)
}
if autoPasteEnabled, !pasted {
return MacL10n.string("mac.status.copied", language: lang)
}
return MacL10n.string("mac.status.copied", language: lang)
}
private func wireHotkeyService() {
hotkeyService.onPressBegan = { [weak self] in
self?.beginRecording()
}
hotkeyService.onPressEnded = { [weak self] in
self?.finishRecording()
}
if hotkeyEnabled { hotkeyService.start() }
}
private func startTimers() {
sessionSeconds = 0
let levelTimer = Timer(timeInterval: 0.05, repeats: true) { [weak self] _ in
guard let self else { return }
Task { @MainActor in self.audioLevel = self.recorder.level() }
}
let sessionTimer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in
guard let self else { return }
Task { @MainActor in self.sessionSeconds += 1 }
}
RunLoop.main.add(levelTimer, forMode: .common)
RunLoop.main.add(sessionTimer, forMode: .common)
self.levelTimer = levelTimer
self.sessionTimer = sessionTimer
}
private func stopTimers() {
levelTimer?.invalidate()
sessionTimer?.invalidate()
levelTimer = nil
sessionTimer = nil
}
private func recordUsage(for text: String) {
usageStatistics.recordUtterance(
text: text,
duration: TimeInterval(sessionSeconds),
wasTranslation: config.isTranslationEffective
)
}
func copyToClipboard(_ text: String) {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(text, forType: .string)
}
func selectProvider(_ provider: LLMProvider) {
config.apply(preset: provider)
}
func refreshForegroundAppName() {
foregroundAppName = MacAppContextService.frontmostApplicationName()
}
}
+241
View File
@@ -0,0 +1,241 @@
// MacDictionaryView.swift
// OSGKeyboard · Mac
//
// Personal dictionary synced via iCloud KVS with the iOS app. Read-only on
// the desktop (words are authored on iPhone / iPad): grouped cards (native
// `Form`) with search, matching the Settings and History card style.
import SwiftUI
struct MacDictionaryView: View {
@ObservedObject var viewModel: MacDictationViewModel
@Environment(\.themePalette) private var palette
@State private var query = ""
@State private var entryPendingDeletion: PersonalDictionary.Entry?
private var lang: AppUILanguage { viewModel.config.uiLanguage }
private var entries: [PersonalDictionary.Entry] {
_ = viewModel.dictionaryRevision
return AppGroupStore(defaults: viewModel.defaults).personalDictionary.entries
}
/// Entries filtered by the search field, grouped by category and sorted
/// (most-used first) mirrors the iOS Personal Dictionary tab.
private var sections: [(category: PersonalDictionary.Entry.Category, items: [PersonalDictionary.Entry])] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let filtered: [PersonalDictionary.Entry]
if trimmed.isEmpty {
filtered = entries
} else {
filtered = entries.filter { entry in
entry.term.lowercased().contains(trimmed)
|| entry.aliases.contains { $0.lowercased().contains(trimmed) }
}
}
let grouped = Dictionary(grouping: filtered, by: { $0.category })
return PersonalDictionary.Entry.Category.allCases.compactMap { category in
guard let bucket = grouped[category], !bucket.isEmpty else { return nil }
let sorted = bucket.sorted {
if $0.usageCount != $1.usageCount { return $0.usageCount > $1.usageCount }
return $0.term.localizedCaseInsensitiveCompare($1.term) == .orderedAscending
}
return (category, sorted)
}
}
var body: some View {
Group {
if entries.isEmpty {
emptyState
.transition(.opacity)
} else {
form
.transition(.opacity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(palette.background)
.animation(Motion.soft, value: entries.isEmpty)
.task {
await MacICloudSyncBootstrap.dictionarySync.pullAndMergeIfEnabled()
viewModel.refreshDictionaryFromCloud()
}
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
viewModel.refreshDictionaryFromCloud()
}
}
// MARK: - Grouped cards
private var form: some View {
Form {
if sections.isEmpty {
Section {
Text(MacL10n.string("mac.dict.noMatch", language: lang))
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .center)
}
} else {
ForEach(sections, id: \.category) { section in
Section(MacL10n.string(section.category.labelKey, language: lang)) {
ForEach(section.items) { entry in
row(entry)
}
}
}
}
}
.formStyle(.grouped)
.scrollContentBackground(.hidden)
.background(palette.background)
.animation(Motion.soft, value: query)
.safeAreaInset(edge: .top, spacing: 0) { centeredSearchField }
.confirmationDialog(
MacL10n.string("mac.dict.deleteTitle", language: lang),
isPresented: deletionDialogBinding,
titleVisibility: .visible
) {
Button(MacL10n.string("mac.delete", language: lang), role: .destructive) {
if let entry = entryPendingDeletion {
withAnimation(Motion.soft) { delete(entry) }
}
entryPendingDeletion = nil
}
Button(MacL10n.string("mac.cancel", language: lang), role: .cancel) {
entryPendingDeletion = nil
}
} message: {
Text(MacL10n.string("mac.dict.deleteMessage", language: lang))
}
}
private var deletionDialogBinding: Binding<Bool> {
Binding(
get: { entryPendingDeletion != nil },
set: { if !$0 { entryPendingDeletion = nil } }
)
}
private var centeredSearchField: some View {
HStack {
Spacer()
HStack(spacing: Spacing.xs) {
Image(systemName: "magnifyingglass")
.foregroundStyle(palette.textTertiary)
TextField(MacL10n.string("mac.dict.search", language: lang), text: $query)
.textFieldStyle(.plain)
}
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 7)
.frame(width: 240)
.macGlassSurface(in: Capsule(), fillOpacity: 0.72)
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
Spacer()
}
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.xs)
.background(palette.background)
}
private func row(_ entry: PersonalDictionary.Entry) -> some View {
MacDictionaryRow(
entry: entry,
subtitle: subtitle(for: entry),
language: lang,
copy: { viewModel.copyToClipboard(entry.term) },
delete: { entryPendingDeletion = entry }
)
}
/// "Manual · ×3 · k8s / kube" same metadata line as iOS.
private func subtitle(for entry: PersonalDictionary.Entry) -> String? {
var parts: [String] = [MacL10n.string(entry.source.labelKey, language: lang)]
if entry.usageCount > 1 {
parts.append("×\(entry.usageCount)")
}
if !entry.aliases.isEmpty {
parts.append(entry.aliases.joined(separator: " / "))
}
return parts.isEmpty ? nil : parts.joined(separator: " · ")
}
// MARK: - Empty state
private var emptyState: some View {
VStack(spacing: Spacing.sm) {
Image(systemName: "character.book.closed")
.font(.system(size: 34))
.foregroundStyle(palette.textTertiary.opacity(0.6))
Text(MacL10n.string("mac.dict.empty", language: lang))
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
Text(MacL10n.string("mac.dict.emptyBody", language: lang))
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.multilineTextAlignment(.center)
.frame(maxWidth: 360)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.horizontal, Spacing.xl)
}
private func delete(_ entry: PersonalDictionary.Entry) {
let store = AppGroupStore(defaults: viewModel.defaults)
store.deletePersonalDictionaryEntry(id: entry.id)
viewModel.refreshDictionaryFromCloud()
Task {
try? await MacICloudSyncBootstrap.dictionarySync.pushLocalIfEnabled(store.personalDictionary)
}
}
}
private struct MacDictionaryRow: View {
let entry: PersonalDictionary.Entry
let subtitle: String?
let language: AppUILanguage
let copy: () -> Void
let delete: () -> Void
@Environment(\.themePalette) private var palette
@State private var isHovering = false
var body: some View {
HStack(alignment: .top, spacing: Spacing.sm) {
VStack(alignment: .leading, spacing: 2) {
Text(entry.term)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
if let subtitle {
Text(subtitle)
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.lineLimit(1)
}
}
Spacer(minLength: Spacing.sm)
Button(action: delete) {
Image(systemName: "trash")
.font(.system(size: 13, weight: .medium))
.frame(width: 24, height: 24)
}
.buttonStyle(.borderless)
.foregroundStyle(isHovering ? palette.danger : palette.textTertiary)
.opacity(isHovering ? 1 : 0)
.accessibilityLabel(MacL10n.string("mac.delete", language: language))
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.animation(Motion.quick, value: isHovering)
.onHover { isHovering = $0 }
.contextMenu {
Button(action: copy) {
Label(MacL10n.string("mac.copy", language: language), systemImage: "doc.on.doc")
}
Button(role: .destructive, action: delete) {
Label(MacL10n.string("mac.delete", language: language), systemImage: "trash")
}
}
}
}
+167
View File
@@ -0,0 +1,167 @@
// MacHistoryView.swift
// OSGKeyboard · Mac
//
// Single-column, day-grouped transcript log rendered as grouped cards (the
// same native `Form` container as Settings). Every entry shows its full text
// inline no master/detail split, so content never pushes the sidebar out.
import SwiftUI
struct MacHistoryView: View {
@ObservedObject var viewModel: MacDictationViewModel
@ObservedObject private var historyStore = SpeechHistoryStore.shared
@Environment(\.themePalette) private var palette
@State private var showClearConfirmation = false
private var lang: AppUILanguage { viewModel.config.uiLanguage }
private static let dayFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .none
return f
}()
private static let timeFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .none
f.timeStyle = .short
return f
}()
var body: some View {
Group {
if historyStore.entries.isEmpty {
emptyState
.transition(.opacity)
} else {
form
.transition(.opacity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(palette.background)
.animation(Motion.soft, value: historyStore.entries.isEmpty)
}
// MARK: - Grouped cards
private var form: some View {
Form {
ForEach(historyStore.groupedByDay, id: \.day) { group in
Section(Self.dayFormatter.string(from: group.day)) {
ForEach(group.items) { entry in
row(entry)
}
}
}
}
.formStyle(.grouped)
.scrollContentBackground(.hidden)
.background(palette.background)
.safeAreaInset(edge: .top, spacing: 0) { toolbar }
.confirmationDialog(
MacL10n.string("mac.history.clearTitle", language: lang),
isPresented: $showClearConfirmation,
titleVisibility: .visible
) {
Button(MacL10n.string("mac.history.clearConfirm", language: lang), role: .destructive) {
withAnimation(Motion.soft) { historyStore.clearAll() }
}
Button(MacL10n.string("mac.cancel", language: lang), role: .cancel) {}
} message: {
Text(MacL10n.string("mac.history.clearMessage", language: lang))
}
}
private var toolbar: some View {
HStack {
Spacer()
Button {
showClearConfirmation = true
} label: {
Label(MacL10n.string("mac.history.clearConfirm", language: lang), systemImage: "trash")
.font(TypeStyle.caption)
}
.buttonStyle(.borderless)
.foregroundStyle(palette.textSecondary)
}
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.xs)
.background(palette.background)
}
private func row(_ entry: SpeechHistoryEntry) -> some View {
MacHistoryRow(
entry: entry,
time: Self.timeFormatter.string(from: entry.createdAt),
language: lang,
copy: { viewModel.copyToClipboard(entry.text) },
delete: { withAnimation(Motion.soft) { historyStore.delete(id: entry.id) } }
)
}
// MARK: - Empty state
private var emptyState: some View {
VStack(spacing: Spacing.sm) {
Image(systemName: "text.bubble")
.font(.system(size: 34))
.foregroundStyle(palette.textTertiary.opacity(0.6))
Text(MacL10n.string("mac.history.empty", language: lang))
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private struct MacHistoryRow: View {
let entry: SpeechHistoryEntry
let time: String
let language: AppUILanguage
let copy: () -> Void
let delete: () -> Void
@Environment(\.themePalette) private var palette
@State private var isHovering = false
var body: some View {
HStack(alignment: .top, spacing: Spacing.sm) {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text(time)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.monospacedDigit()
Text(entry.text)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: Spacing.sm)
Button(action: delete) {
Image(systemName: "trash")
.font(.system(size: 13, weight: .medium))
.frame(width: 24, height: 24)
}
.buttonStyle(.borderless)
.foregroundStyle(isHovering ? palette.danger : palette.textTertiary)
.opacity(isHovering ? 1 : 0)
.accessibilityLabel(MacL10n.string("mac.delete", language: language))
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.animation(Motion.quick, value: isHovering)
.onHover { isHovering = $0 }
.contextMenu {
Button(action: copy) {
Label(MacL10n.string("mac.copy", language: language), systemImage: "doc.on.doc")
}
Button(role: .destructive, action: delete) {
Label(MacL10n.string("mac.delete", language: language), systemImage: "trash")
}
}
}
}
+67
View File
@@ -0,0 +1,67 @@
// MacHotkeyService.swift
// OSGKeyboard · Mac
//
// Global hold-to-talk: while Option () is held, dictation runs. Mirrors
// Typeless / SayIt push-to-talk from any foreground app.
import AppKit
import Foundation
@MainActor
final class MacHotkeyService {
var onPressBegan: (() -> Void)?
var onPressEnded: (() -> Void)?
private var globalFlagsMonitor: Any?
private var localFlagsMonitor: Any?
private var optionHeld = false
private var isEnabled = true
func setEnabled(_ enabled: Bool) {
isEnabled = enabled
if !enabled, optionHeld {
optionHeld = false
onPressEnded?()
}
}
func start() {
guard globalFlagsMonitor == nil else { return }
_ = MacTextInsertionService.requestAccessibilityIfNeeded()
globalFlagsMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
Task { @MainActor in self?.handleFlagsChanged(event) }
}
localFlagsMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
Task { @MainActor in self?.handleFlagsChanged(event) }
return event
}
}
func stop() {
if let globalFlagsMonitor {
NSEvent.removeMonitor(globalFlagsMonitor)
self.globalFlagsMonitor = nil
}
if let localFlagsMonitor {
NSEvent.removeMonitor(localFlagsMonitor)
self.localFlagsMonitor = nil
}
if optionHeld {
optionHeld = false
onPressEnded?()
}
}
private func handleFlagsChanged(_ event: NSEvent) {
guard isEnabled else { return }
let optionDown = event.modifierFlags.contains(.option)
if optionDown, !optionHeld {
optionHeld = true
onPressBegan?()
} else if !optionDown, optionHeld {
optionHeld = false
onPressEnded?()
}
}
}
@@ -0,0 +1,40 @@
// MacICloudSyncBootstrap.swift
// OSGKeyboard · Mac
//
// Wires the shared iCloud KVS sync layer to macOS UserDefaults so settings
// and personal dictionary stay aligned with the iOS app.
import Foundation
@MainActor
enum MacICloudSyncBootstrap {
private static var configured = false
private static var cloudSync: AppCloudSync?
static func configure(defaults: UserDefaults) {
guard !configured else { return }
configured = true
let makeStore = { AppGroupStore(defaults: defaults) }
cloudSync = AppCloudSync(makeStore: makeStore, historyDefaults: { defaults })
cloudSync?.startObservingExternalChanges()
}
static func pullIfEnabled() async {
await cloudSync?.pullAllIfEnabled()
}
static var settingsSync: SettingsCloudSync {
if let cloudSync {
return cloudSync.settingsSyncService
}
return SettingsCloudSync(makeStore: { AppGroupStore(defaults: .standard) })
}
static var dictionarySync: PersonalDictionaryCloudSync {
cloudSync?.dictionarySyncService ?? PersonalDictionaryCloudSync(makeStore: { AppGroupStore(defaults: .standard) })
}
static var appCloudSync: AppCloudSync {
cloudSync ?? AppCloudSync.shared
}
}
+180
View File
@@ -0,0 +1,180 @@
// MacICloudSyncRows.swift
// OSGKeyboard · Mac
//
// iCloud sync toggles for settings and personal dictionary same KVS
// keys and merge rules as the iOS settings page. Rendered as native Form
// rows (Toggle + optional action / error) so they sit inside a grouped
// `Form` section and match System Settings exactly.
import SwiftUI
struct MacSettingsICloudSyncRow: View {
let defaults: UserDefaults
let language: AppUILanguage
@Environment(\.themePalette) private var palette
@State private var isEnabled = false
@State private var syncErrorMessage: String?
@State private var isApplyingToggle = false
@State private var isSyncingNow = false
var body: some View {
Toggle(isOn: toggleBinding) {
VStack(alignment: .leading, spacing: 2) {
Text(MacL10n.string("mac.sync.settingsTitle", language: language))
Text(MacL10n.string("mac.sync.settingsSubtitle", language: language))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.tint(palette.accent)
.disabled(isApplyingToggle)
.onAppear { reloadFromStore() }
.onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in
reloadFromStore()
}
if isEnabled {
Button {
syncNow()
} label: {
HStack(spacing: Spacing.xs) {
if isSyncingNow { ProgressView().controlSize(.small) }
Text(MacL10n.string("mac.sync.syncNow", language: language))
}
}
.disabled(isSyncingNow || isApplyingToggle)
}
if let syncErrorMessage {
Text(syncErrorMessage)
.font(TypeStyle.caption)
.foregroundStyle(palette.danger)
}
}
private var toggleBinding: Binding<Bool> {
Binding(
get: { isEnabled },
set: { newValue in
guard newValue != isEnabled else { return }
newValue ? enableSync() : disableSync()
}
)
}
private func reloadFromStore() {
isEnabled = AppGroupStore(defaults: defaults).settingsICloudSyncEnabled
}
private func enableSync() {
isApplyingToggle = true
syncErrorMessage = nil
Task {
do {
try await MacICloudSyncBootstrap.settingsSync.enableSync()
reloadFromStore()
} catch {
isEnabled = false
syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language)
}
isApplyingToggle = false
}
}
private func disableSync() {
MacICloudSyncBootstrap.settingsSync.disableSync()
isEnabled = false
syncErrorMessage = nil
}
private func syncNow() {
isSyncingNow = true
syncErrorMessage = nil
Task {
do {
try await MacICloudSyncBootstrap.appCloudSync.syncNow()
} catch {
syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language)
}
isSyncingNow = false
}
}
}
struct MacDictionaryICloudSyncRow: View {
let defaults: UserDefaults
let language: AppUILanguage
@Environment(\.themePalette) private var palette
@State private var isEnabled = false
@State private var syncErrorMessage: String?
@State private var isApplyingToggle = false
var body: some View {
Toggle(isOn: toggleBinding) {
VStack(alignment: .leading, spacing: 2) {
Text(MacL10n.string("mac.sync.dictTitle", language: language))
Text(MacL10n.string("mac.sync.dictSubtitle", language: language))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.tint(palette.accent)
.disabled(isApplyingToggle)
.onAppear { reloadFromStore() }
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
reloadFromStore()
}
if let syncErrorMessage {
Text(syncErrorMessage)
.font(TypeStyle.caption)
.foregroundStyle(palette.danger)
}
}
private var toggleBinding: Binding<Bool> {
Binding(
get: { isEnabled },
set: { newValue in
guard newValue != isEnabled else { return }
newValue ? enableSync() : disableSync()
}
)
}
private func reloadFromStore() {
isEnabled = AppGroupStore(defaults: defaults).personalDictionaryICloudSyncEnabled
}
private func enableSync() {
isApplyingToggle = true
syncErrorMessage = nil
Task {
do {
try await MacICloudSyncBootstrap.dictionarySync.enableSync()
reloadFromStore()
} catch let error as PersonalDictionaryCloudSyncError {
isEnabled = false
if case .payloadTooLarge = error {
syncErrorMessage = MacL10n.string("mac.sync.error.dictTooLarge", language: language)
} else {
syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language)
}
} catch {
isEnabled = false
syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language)
}
isApplyingToggle = false
}
}
private func disableSync() {
MacICloudSyncBootstrap.dictionarySync.disableSync()
isEnabled = false
syncErrorMessage = nil
}
}
+17
View File
@@ -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)
}
}
+110
View File
@@ -0,0 +1,110 @@
// MacLegalSettingsViews.swift
// OSGKeyboard · Mac
//
// Privacy policy and third-party license screens (mirrors iOS Settings footer).
import SwiftUI
struct MacPrivacyPolicyView: View {
let uiLanguage: AppUILanguage
@Environment(\.themePalette) private var palette
var body: some View {
MacLegalWebView(
resourceName: "PrivacyPolicy",
scrollToAnchor: privacyScrollAnchor
)
.background(palette.background)
.navigationTitle(MacL10n.string("mac.settings.privacyPolicy", language: uiLanguage))
}
private var privacyScrollAnchor: String? {
switch uiLanguage {
case .chinese:
return "zh"
case .english:
return "top"
case .auto:
return uiLanguage.resolvedLanguageCode().hasPrefix("zh") ? "zh" : "top"
}
}
}
struct MacOpenSourceLicensesView: View {
let uiLanguage: AppUILanguage
@Environment(\.themePalette) private var palette
var body: some View {
List {
Section {
Text(MacL10n.string("mac.settings.licenses.footer", language: uiLanguage))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.listRowBackground(Color.clear)
}
Section {
ForEach(OpenSourceLicenseCatalog.entries) { entry in
NavigationLink {
MacOpenSourceLicenseDetailView(entry: entry, uiLanguage: uiLanguage)
} label: {
HStack {
Text(entry.name)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: Spacing.sm)
Text(entry.licenseName)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
}
}
}
}
.scrollContentBackground(.hidden)
.background(palette.background)
.navigationTitle(MacL10n.string("mac.settings.thirdPartyLicenses", language: uiLanguage))
}
}
private struct MacOpenSourceLicenseDetailView: View {
let entry: OpenSourceLicenseCatalog.Entry
let uiLanguage: AppUILanguage
@Environment(\.themePalette) private var palette
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: Spacing.sm) {
if let url = entry.url {
Link(destination: url) {
HStack(spacing: Spacing.xs) {
Text(url.absoluteString)
.font(TypeStyle.caption)
.foregroundStyle(palette.accent)
.lineLimit(2)
.multilineTextAlignment(.leading)
Spacer(minLength: 0)
Image(systemName: "arrow.up.right.square")
.font(.system(size: 12))
.foregroundStyle(palette.textTertiary)
}
}
}
Text(entry.purpose)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
Text(entry.licenseText)
.font(TypeStyle.monoSmall)
.foregroundStyle(palette.textPrimary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(Spacing.lg)
}
.background(palette.background)
.navigationTitle(entry.name)
}
}
+48
View File
@@ -0,0 +1,48 @@
// MacLegalWebView.swift
// OSGKeyboard · Mac
//
// In-app HTML viewer for bundled legal documents (privacy policy).
import SwiftUI
import WebKit
struct MacLegalWebView: NSViewRepresentable {
let resourceName: String
var scrollToAnchor: String?
func makeCoordinator() -> Coordinator {
Coordinator(scrollToAnchor: scrollToAnchor)
}
func makeNSView(context: Context) -> WKWebView {
let webView = WKWebView(frame: .zero)
webView.setValue(false, forKey: "drawsBackground")
webView.navigationDelegate = context.coordinator
context.coordinator.webView = webView
guard let url = Bundle.main.url(forResource: resourceName, withExtension: "html") else {
return webView
}
webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
return webView
}
func updateNSView(_ nsView: WKWebView, context: Context) {
context.coordinator.scrollToAnchor = scrollToAnchor
}
final class Coordinator: NSObject, WKNavigationDelegate {
var scrollToAnchor: String?
weak var webView: WKWebView?
init(scrollToAnchor: String?) {
self.scrollToAnchor = scrollToAnchor
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
guard let anchor = scrollToAnchor, !anchor.isEmpty else { return }
let escaped = anchor.replacingOccurrences(of: "'", with: "\\'")
webView.evaluateJavaScript("location.hash = '#\(escaped)';") { _, _ in }
}
}
}
@@ -0,0 +1,436 @@
// MacLocalASRModelSettingsView.swift
// OSGKeyboard · Mac
//
// Local ASR model catalog, download progress, and bias diagnostics.
import AppKit
import SwiftUI
@MainActor
final class MacLocalASRModelSettingsViewModel: ObservableObject {
@Published var catalog: LocalASRCatalogDocument?
@Published var selectedModelId: String = MacLocalASRPreferences.selectedModelId
@Published var installProgress = LocalASRModelInstallProgress.idle
@Published var diagnosticsSnapshot = LocalASRBiasDiagnosticsStore.load()
@Published var statusMessage = ""
@Published var isInstalling = false
@Published var isDownloadPaused = false
var onLocalModelStateChanged: (() -> Void)?
private let manager = LocalASRModelManager.shared
private var progressPollTask: Task<Void, Never>?
deinit {
progressPollTask?.cancel()
}
func reload() {
catalog = try? LocalASRModelCatalog.loadBundled()
if let catalog {
let manifest = LocalASRInstalledManifestIO.load(defaultModelId: catalog.defaultModelId)
selectedModelId = MacLocalASRPreferences.migratedModelId(
manifest.selectedModelId.isEmpty
? MacLocalASRPreferences.selectedModelId
: manifest.selectedModelId
)
}
diagnosticsSnapshot = LocalASRBiasDiagnosticsStore.load()
onLocalModelStateChanged?()
}
func isInstalled(_ model: LocalASRModelDefinition) -> Bool {
MacLocalASRService.isModelInstalled(model)
}
func isInstallingModel(_ model: LocalASRModelDefinition) -> Bool {
isInstalling && installProgress.activeItemId == model.id
}
func installedDiskUsage(_ model: LocalASRModelDefinition) -> String? {
guard model.installKind == .archive || model.installKind == .repository,
let relative = model.installRelativePath,
isInstalled(model) else { return nil }
let dir = LocalASRModelInstallState.installDirectory(for: relative)
let bytes = LocalASRModelInstallState.directoryByteCount(at: dir)
guard bytes > 0 else { return nil }
return ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
}
func currentRuntime(in catalog: LocalASRCatalogDocument) -> LocalASRRuntimeDefinition? {
LocalASRModelCatalog.runtime(for: LocalASRModelCatalog.currentRuntimePlatform(), in: catalog)
}
func isRuntimeInstalled(_ runtime: LocalASRRuntimeDefinition) -> Bool {
LocalASRModelInstallState.isRuntimeInstalled(runtime)
}
func selectModel(_ modelId: String) {
guard let catalog, !isInstalling else { return }
selectedModelId = modelId
MacLocalASRPreferences.selectedModelId = modelId
var manifest = LocalASRInstalledManifestIO.load(defaultModelId: catalog.defaultModelId)
manifest.selectedModelId = modelId
manifest.updatedAt = Date()
try? LocalASRInstalledManifestIO.save(manifest)
onLocalModelStateChanged?()
}
func installModel(_ model: LocalASRModelDefinition) {
guard let catalog, !isInstalling else { return }
statusMessage = ""
isInstalling = true
isDownloadPaused = false
startProgressPolling()
Task {
do {
try await manager.installModel(model, catalog: catalog)
installProgress = await manager.currentProgress()
selectModel(model.id)
statusMessage = MacL10n.string("mac.localASR.installDone")
} catch {
installProgress = await manager.currentProgress()
statusMessage = error.localizedDescription
}
isInstalling = false
isDownloadPaused = false
stopProgressPolling()
reload()
}
}
func pauseDownload() {
Task {
do {
try await manager.pauseDownload()
isDownloadPaused = true
installProgress = await manager.currentProgress()
} catch {
statusMessage = error.localizedDescription
}
}
}
func resumeDownload() {
Task {
do {
try await manager.resumeDownload()
isDownloadPaused = false
installProgress = await manager.currentProgress()
} catch {
statusMessage = error.localizedDescription
}
}
}
func deleteModel(_ model: LocalASRModelDefinition) {
guard let catalog, !isInstalling else { return }
Task {
do {
try await manager.deleteModel(model, catalog: catalog)
statusMessage = MacL10n.string("mac.localASR.deleteDone")
reload()
} catch {
statusMessage = error.localizedDescription
}
}
}
func revealModelInFinder(_ model: LocalASRModelDefinition) {
guard let relative = model.installRelativePath else { return }
let url = LocalASRModelInstallState.installDirectory(for: relative)
NSWorkspace.shared.activateFileViewerSelecting([url])
}
func revealStorageRoot() {
let url = LocalASRModelInstallState.rootDirectory()
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
NSWorkspace.shared.open(url)
}
func progressLabel(for progress: LocalASRModelInstallProgress, language: AppUILanguage) -> String {
let phaseKey: String
switch progress.phase {
case .downloading: phaseKey = "mac.localASR.phase.downloading"
case .paused: phaseKey = "mac.localASR.phase.paused"
case .extracting: phaseKey = "mac.localASR.phase.extracting"
case .validating: phaseKey = "mac.localASR.phase.validating"
case .finalizing: phaseKey = "mac.localASR.phase.finalizing"
case .failed: phaseKey = "mac.localASR.phase.failed"
case .completed: phaseKey = "mac.localASR.phase.completed"
case .idle: return progress.message
}
let phase = MacL10n.string(phaseKey, language: language)
if let received = progress.bytesReceived, let total = progress.bytesTotal, total > 0 {
let recv = ByteCountFormatter.string(fromByteCount: received, countStyle: .file)
let tot = ByteCountFormatter.string(fromByteCount: total, countStyle: .file)
return "\(phase) · \(progress.message) (\(recv) / \(tot))"
}
return "\(phase) · \(progress.message)"
}
private func startProgressPolling() {
progressPollTask?.cancel()
progressPollTask = Task { [weak self] in
while !Task.isCancelled {
guard let self else { return }
let current = await manager.currentProgress()
await MainActor.run {
self.installProgress = current
self.isDownloadPaused = current.phase == .paused
}
try? await Task.sleep(for: .milliseconds(120))
}
}
}
private func stopProgressPolling() {
progressPollTask?.cancel()
progressPollTask = nil
}
func formattedSize(_ bytes: Int) -> String {
ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file)
}
}
struct MacLocalASRModelSettingsView: View {
@ObservedObject var viewModel: MacDictationViewModel
@StateObject private var modelVM = MacLocalASRModelSettingsViewModel()
@Environment(\.themePalette) private var palette
private var lang: AppUILanguage { viewModel.config.uiLanguage }
var body: some View {
Group {
if let catalog = modelVM.catalog {
modelPickerSection(catalog: catalog)
} else {
Text(MacL10n.string("mac.localASR.catalogMissing", language: lang))
.foregroundStyle(palette.textSecondary)
}
}
.onAppear {
modelVM.onLocalModelStateChanged = { viewModel.bumpLocalModelRevision() }
modelVM.reload()
}
}
private func modelPickerSection(catalog: LocalASRCatalogDocument) -> some View {
Section {
if let runtime = modelVM.currentRuntime(in: catalog) {
LabeledContent(runtime.displayName) {
Text(
modelVM.isRuntimeInstalled(runtime)
? MacL10n.string("mac.localASR.installed", language: lang)
: MacL10n.string("mac.localASR.notInstalled", language: lang)
)
}
}
ForEach(catalog.models) { model in
modelRow(model)
}
if modelVM.isInstalling,
modelVM.installProgress.phase == .extracting
|| modelVM.installProgress.phase == .validating
|| modelVM.installProgress.phase == .finalizing {
ProgressView(value: modelVM.installProgress.fraction) {
Text(modelVM.progressLabel(for: modelVM.installProgress, language: lang))
.font(TypeStyle.caption)
}
}
if !modelVM.statusMessage.isEmpty {
Text(modelVM.statusMessage)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
Button(MacL10n.string("mac.localASR.openStorage", language: lang)) {
modelVM.revealStorageRoot()
}
} header: {
Text(MacL10n.string("mac.localASR.models", language: lang))
}
}
@ViewBuilder
private func modelRow(_ model: LocalASRModelDefinition) -> some View {
let installed = modelVM.isInstalled(model)
let selected = modelVM.selectedModelId == model.id
let installing = modelVM.isInstallingModel(model)
VStack(alignment: .leading, spacing: Spacing.xs) {
HStack(alignment: .top) {
Button {
modelVM.selectModel(model.id)
} label: {
HStack(spacing: Spacing.sm) {
Image(systemName: selected ? "largecircle.fill.circle" : "circle")
.foregroundStyle(selected ? palette.accent : palette.textTertiary)
.contentTransition(.symbolEffect(.replace))
.animation(Motion.quick, value: selected)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: Spacing.xs) {
Text(model.displayName)
.foregroundStyle(palette.textPrimary)
if model.supportsHotwords {
Text(MacL10n.string("mac.localASR.personalDictionaryTag", language: lang))
.font(TypeStyle.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(palette.accent.opacity(0.15))
.foregroundStyle(palette.accent)
.clipShape(Capsule())
}
}
Text(modelSubtitle(model, installed: installed))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
}
}
.buttonStyle(.plain)
.disabled(modelVM.isInstalling)
Spacer()
modelRowActions(model: model, installed: installed, installing: installing)
.animation(Motion.soft, value: installing)
.animation(Motion.soft, value: installed)
}
}
.padding(.vertical, 2)
}
@ViewBuilder
private func modelRowActions(
model: LocalASRModelDefinition,
installed: Bool,
installing: Bool
) -> some View {
if installing {
HStack(spacing: Spacing.sm) {
circularInstallProgress(for: model)
if modelVM.installProgress.phase == .downloading
|| modelVM.installProgress.phase == .paused {
Button {
if modelVM.isDownloadPaused {
modelVM.resumeDownload()
} else {
modelVM.pauseDownload()
}
} label: {
Image(systemName: modelVM.isDownloadPaused ? "play.fill" : "pause.fill")
.font(.system(size: 12, weight: .semibold))
.frame(width: 28, height: 28)
}
.buttonStyle(.bordered)
.controlSize(.small)
.help(
modelVM.isDownloadPaused
? MacL10n.string("mac.localASR.resume", language: lang)
: MacL10n.string("mac.localASR.pause", language: lang)
)
}
}
} else if installed {
Button(MacL10n.string("mac.localASR.delete", language: lang), role: .destructive) {
modelVM.deleteModel(model)
}
.buttonStyle(.bordered)
.controlSize(.small)
.tint(palette.danger)
} else {
Button(MacL10n.string("mac.localASR.download", language: lang)) {
modelVM.installModel(model)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
}
private func circularInstallProgress(for model: LocalASRModelDefinition) -> some View {
let fraction: Double = {
if modelVM.installProgress.phase == .downloading || modelVM.installProgress.phase == .paused,
let received = modelVM.installProgress.bytesReceived,
let total = modelVM.installProgress.bytesTotal,
total > 0 {
return min(1, max(0, Double(received) / Double(total)))
}
return modelVM.installProgress.fraction
}()
return ZStack {
Circle()
.stroke(palette.textTertiary.opacity(0.25), lineWidth: 3)
Circle()
.trim(from: 0, to: fraction)
.stroke(palette.accent, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(Motion.instant, value: fraction)
if modelVM.installProgress.phase == .paused {
Image(systemName: "pause.fill")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(palette.textSecondary)
} else {
Text("\(Int(fraction * 100))%")
.font(.system(size: 9, weight: .medium, design: .rounded))
.foregroundStyle(palette.textSecondary)
}
}
.frame(width: 36, height: 36)
.accessibilityLabel(modelVM.progressLabel(for: modelVM.installProgress, language: lang))
}
private func modelSubtitle(_ model: LocalASRModelDefinition, installed: Bool) -> String {
let size = modelVM.formattedSize(model.sizeBytes)
if let usage = modelVM.installedDiskUsage(model) {
return "\(size) · \(usage)"
}
return size
}
private var diagnosticsSection: some View {
Section {
if let snapshot = modelVM.diagnosticsSnapshot {
LabeledContent(MacL10n.string("mac.localASR.diagBackend", language: lang)) {
Text(snapshot.backendLabel ?? "")
}
LabeledContent(MacL10n.string("mac.localASR.diagUserTerms", language: lang)) {
Text("\(snapshot.diagnostics.userTermCount)")
}
LabeledContent(MacL10n.string("mac.localASR.diagBuiltinTerms", language: lang)) {
Text("\(snapshot.diagnostics.builtinTermCount)")
}
LabeledContent(MacL10n.string("mac.localASR.diagHotwords", language: lang)) {
Text("\(snapshot.hotwordCount)")
}
LabeledContent(MacL10n.string("mac.localASR.diagPrompt", language: lang)) {
Text("\(snapshot.promptBiasLength)")
}
if snapshot.diagnostics.truncated {
Label(
snapshot.diagnostics.truncationReason ?? MacL10n.string("mac.localASR.diagTruncated", language: lang),
systemImage: "exclamationmark.triangle"
)
.font(TypeStyle.caption)
.foregroundStyle(palette.warning)
}
Text(snapshot.diagnostics.selectedSources.joined(separator: ", "))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
} else {
Text(MacL10n.string("mac.localASR.diagEmpty", language: lang))
.foregroundStyle(palette.textSecondary)
}
} header: {
Text(MacL10n.string("mac.localASR.diagnostics", language: lang))
} footer: {
Text(MacL10n.string("mac.localASR.diagnosticsDesc", language: lang))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
}
}
+147
View File
@@ -0,0 +1,147 @@
// MacLocalASRService.swift
// OSGKeyboard · Mac
//
// On-device ASR for macOS. Routes through the bundled local ASR catalog:
// Sherpa Qwen3 (default), Paraformer, SenseVoice, Apple Speech fallback.
import Foundation
enum MacLocalASRBackend: String, Sendable, CaseIterable {
case sherpaQwen3
case sherpaParaformer
case sherpaSenseVoice
case appleSpeech
}
enum MacLocalASRError: Error, LocalizedError {
case qwen3ModelMissing
case qwen3LoadFailed(String)
case qwen3InferenceFailed(String)
case speechDenied
case speechFailed(String)
case emptyTranscript
var errorDescription: String? {
switch self {
case .qwen3ModelMissing:
return MacL10n.string("mac.error.qwen3ModelMissing")
case .qwen3LoadFailed(let detail):
return MacL10n.format("mac.error.qwen3LoadFailed", detail)
case .qwen3InferenceFailed(let detail):
return MacL10n.format("mac.error.qwen3InferenceFailed", detail)
case .speechDenied:
return "Speech recognition permission denied"
case .speechFailed(let detail):
return detail
case .emptyTranscript:
return MacL10n.string("mac.error.emptyTranscript")
}
}
}
enum MacLocalASRPreferences {
static let backendKey = "mac.localASR.backend"
static let selectedModelIdKey = LocalASRPreferenceKeys.selectedModelId
/// Legacy MLX path key retained for migration only.
static let qwen3ModelRelativePath = "models/qwen3-asr-1.7b-mlx"
static var selectedModelId: String {
get {
if let raw = UserDefaults.standard.string(forKey: selectedModelIdKey), !raw.isEmpty {
return migratedModelId(raw)
}
return legacyBackend == .appleSpeech ? "apple-speech-fallback" : "sherpa-qwen3-0.6b-int8"
}
set { UserDefaults.standard.set(newValue, forKey: selectedModelIdKey) }
}
/// Maps removed catalog entries to the current default Sherpa model.
static func migratedModelId(_ id: String) -> String {
switch id {
case "qwen3-mlx-1.7b":
return "sherpa-qwen3-0.6b-int8"
default:
return id
}
}
static var legacyBackend: MacLocalASRBackend {
guard let raw = UserDefaults.standard.string(forKey: backendKey),
let value = MacLocalASRBackend(rawValue: raw) else {
return .sherpaQwen3
}
if raw == "qwen3MLX" { return .sherpaQwen3 }
return value
}
}
enum MacLocalASRService {
static func loadCatalog() -> LocalASRCatalogDocument? {
try? LocalASRModelCatalog.loadBundled()
}
static func selectedModelDefinition() -> LocalASRModelDefinition? {
guard let catalog = loadCatalog() else { return nil }
let manifest = LocalASRInstalledManifestIO.load(defaultModelId: catalog.defaultModelId)
let selectedId = manifest.selectedModelId.isEmpty
? MacLocalASRPreferences.selectedModelId
: MacLocalASRPreferences.migratedModelId(manifest.selectedModelId)
if selectedId == "apple-speech-fallback" { return nil }
return LocalASRModelCatalog.model(selectedId, in: catalog)
?? LocalASRModelCatalog.model(catalog.defaultModelId, in: catalog)
}
static func currentCapabilities() -> LocalASRCapabilities {
guard let model = selectedModelDefinition() else { return .appleSpeech }
return LocalASRModelCatalog.capabilities(for: model)
}
static func currentBackendLabel() -> String {
guard let model = selectedModelDefinition() else { return "Apple Speech" }
return model.displayName
}
static func isModelInstalled(_ model: LocalASRModelDefinition) -> Bool {
LocalASRModelInstallState.isInstalled(
model,
manualMLXPath: nil,
fileManager: FileManager.default
)
}
/// Transcribe using the selected catalog model, falling back to Apple Speech.
static func transcribe(
samples: [Float],
locale: Locale,
bias: LocalASRBiasPayload? = nil
) async throws -> String {
if let model = selectedModelDefinition(), isModelInstalled(model) {
return try await transcribeWithModel(model, samples: samples, locale: locale, bias: bias)
}
return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale)
}
private static func transcribeWithModel(
_ model: LocalASRModelDefinition,
samples: [Float],
locale: Locale,
bias: LocalASRBiasPayload?
) async throws -> String {
switch model.backend {
case .mlx:
throw MacLocalASRError.qwen3ModelMissing
case .sherpaQwen3, .sherpaSenseVoice, .sherpaParaformer:
return try await MacSherpaLocalASR.transcribe(
samples: samples,
sampleRate: 16_000,
locale: locale,
model: model,
bias: bias
)
case .appleSpeech:
return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale)
}
}
}
+699
View File
@@ -0,0 +1,699 @@
// MacOnboardingView.swift
// OSGKeyboard · Mac
//
// A short first-run setup for the macOS app. It is intentionally separate
// from iOS onboarding because Mac needs Accessibility and optional Sherpa setup.
//
// Visual language mirrors the iOS onboarding: an ambient top gradient, a
// glowing hero icon, a large title block, and elongated capsule progress
// dots all carried by whitespace and a single accent colour.
import AppKit
import AVFoundation
import SwiftUI
enum MacOnboardingState {
static let storageKey = "mac.hasCompletedOnboarding"
}
private enum MacOnboardingStep: Int, CaseIterable {
case welcome
case microphone
case accessibility
case engine
case cloudAPI
case localModel
var systemImage: String {
switch self {
case .welcome: return "sparkles"
case .microphone: return "mic.fill"
case .accessibility: return "accessibility"
case .engine: return "switch.2"
case .cloudAPI: return "key.fill"
case .localModel: return "arrow.down.circle.fill"
}
}
}
@MainActor
private final class MacOnboardingViewModel: ObservableObject {
@Published var step: MacOnboardingStep = .welcome
@Published var micStatus = AVCaptureDevice.authorizationStatus(for: .audio)
@Published var accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
@Published var catalog: LocalASRCatalogDocument?
@Published var installProgress = LocalASRModelInstallProgress.idle
@Published var isInstalling = false
@Published var statusMessage = ""
private let manager = LocalASRModelManager.shared
private var progressPollTask: Task<Void, Never>?
deinit {
progressPollTask?.cancel()
}
var defaultModel: LocalASRModelDefinition? {
guard let catalog else { return nil }
return catalog.models.first { $0.id == catalog.defaultModelId }
}
var isDefaultModelInstalled: Bool {
guard let defaultModel else { return false }
return MacLocalASRService.isModelInstalled(defaultModel)
}
func reload() {
catalog = try? LocalASRModelCatalog.loadBundled()
micStatus = AVCaptureDevice.authorizationStatus(for: .audio)
accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
}
func requestMicrophone() {
AVCaptureDevice.requestAccess(for: .audio) { [weak self] _ in
Task { @MainActor in
self?.micStatus = AVCaptureDevice.authorizationStatus(for: .audio)
}
}
}
func openAccessibilitySettings() {
_ = MacTextInsertionService.requestAccessibilityIfNeeded()
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
NSWorkspace.shared.open(url)
}
refreshAccessibilitySoon()
}
func refreshAccessibilitySoon() {
accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
self?.accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
}
}
func installDefaultModel() {
guard let catalog, let model = defaultModel, !isInstalling else { return }
statusMessage = ""
isInstalling = true
startProgressPolling()
Task {
do {
try await manager.installModel(model, catalog: catalog)
installProgress = await manager.currentProgress()
selectInstalledModel(model.id, catalog: catalog)
statusMessage = MacL10n.string("mac.onboarding.model.done")
} catch {
installProgress = await manager.currentProgress()
statusMessage = error.localizedDescription
}
isInstalling = false
stopProgressPolling()
reload()
}
}
func progressLabel(language: AppUILanguage) -> String {
let phase: String
switch installProgress.phase {
case .idle: return installProgress.message
case .downloading: phase = MacL10n.string("mac.localASR.phase.downloading", language: language)
case .paused: phase = MacL10n.string("mac.localASR.phase.paused", language: language)
case .extracting: phase = MacL10n.string("mac.localASR.phase.extracting", language: language)
case .validating: phase = MacL10n.string("mac.localASR.phase.validating", language: language)
case .finalizing: phase = MacL10n.string("mac.localASR.phase.finalizing", language: language)
case .failed: phase = MacL10n.string("mac.localASR.phase.failed", language: language)
case .completed: phase = MacL10n.string("mac.localASR.phase.completed", language: language)
}
guard !installProgress.message.isEmpty else { return phase }
return "\(phase) · \(installProgress.message)"
}
private func selectInstalledModel(_ modelId: String, catalog: LocalASRCatalogDocument) {
MacLocalASRPreferences.selectedModelId = modelId
var manifest = LocalASRInstalledManifestIO.load(defaultModelId: catalog.defaultModelId)
manifest.selectedModelId = modelId
manifest.updatedAt = Date()
try? LocalASRInstalledManifestIO.save(manifest)
}
private func startProgressPolling() {
progressPollTask?.cancel()
progressPollTask = Task { [weak self] in
while !Task.isCancelled {
let current = await LocalASRModelManager.shared.currentProgress()
await MainActor.run { self?.installProgress = current }
try? await Task.sleep(nanoseconds: 120_000_000)
}
}
}
private func stopProgressPolling() {
progressPollTask?.cancel()
progressPollTask = nil
}
}
// MARK: - Root
struct MacOnboardingView: View {
@ObservedObject var viewModel: MacDictationViewModel
@Binding var hasCompletedOnboarding: Bool
@Environment(\.themePalette) private var palette
@Environment(\.colorScheme) private var colorScheme
@StateObject private var model = MacOnboardingViewModel()
@State private var contentAppeared = false
private var lang: AppUILanguage { viewModel.config.uiLanguage }
private var visibleSteps: [MacOnboardingStep] {
if viewModel.config.engineMode == "cloud" {
return [.welcome, .microphone, .accessibility, .engine, .cloudAPI]
}
return [.welcome, .microphone, .accessibility, .engine, .localModel]
}
var body: some View {
GeometryReader { geo in
ZStack(alignment: .top) {
background(height: geo.size.height)
VStack(spacing: 0) {
Spacer(minLength: Spacing.xl)
hero
.id(model.step)
.transition(stepTransition)
Spacer(minLength: Spacing.lg)
progressDots
.padding(.bottom, Spacing.lg)
bottomBar
.padding(.horizontal, Spacing.xxxl)
.padding(.bottom, Spacing.xxl)
}
.frame(maxWidth: .infinity)
}
}
.frame(minWidth: 860, minHeight: 600)
.onAppear {
applyDefaults()
model.reload()
withAnimation(.spring(response: 0.7, dampingFraction: 0.85)) {
contentAppeared = true
}
}
}
// MARK: Background
private func background(height: CGFloat) -> some View {
ZStack(alignment: .top) {
palette.background.ignoresSafeArea()
LinearGradient(
colors: [
palette.accent.opacity(0.12),
palette.accent.opacity(0.03),
palette.background.opacity(0)
],
startPoint: .top,
endPoint: .bottom
)
.frame(height: height * 0.42)
.ignoresSafeArea(edges: .top)
.allowsHitTesting(false)
}
}
// MARK: Hero + content
private var hero: some View {
VStack(spacing: Spacing.lg) {
heroIcon
VStack(spacing: Spacing.sm) {
Text(title)
.font(TypeStyle.title2)
.foregroundStyle(palette.textPrimary)
.multilineTextAlignment(.center)
Text(subtitle)
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: 460)
}
stepContent
.frame(maxWidth: 460)
.padding(.top, Spacing.xs)
}
.padding(.horizontal, Spacing.xxl)
.opacity(contentAppeared ? 1 : 0)
.offset(y: contentAppeared ? 0 : 12)
}
@ViewBuilder
private var heroIcon: some View {
if model.step == .welcome {
Image("OSGBrandMark")
.renderingMode(.template)
.resizable()
.scaledToFit()
.frame(width: 128, height: 128)
.foregroundStyle(colorScheme == .dark ? Color.white : palette.accent)
.accessibilityLabel("OSGKeyboard")
} else {
ZStack {
Circle()
.fill(palette.accentGlow)
.frame(width: 116, height: 116)
.blur(radius: 26)
Circle()
.fill(palette.accentMuted)
.frame(width: 92, height: 92)
.overlay(Circle().stroke(palette.accent.opacity(0.25), lineWidth: 1))
Image(systemName: model.step.systemImage)
.font(.system(size: 40, weight: .semibold))
.foregroundStyle(palette.accent)
.symbolRenderingMode(.hierarchical)
}
}
}
@ViewBuilder
private var stepContent: some View {
switch model.step {
case .welcome:
featureList
case .microphone:
permissionCard(
isGranted: model.micStatus == .authorized,
grantedText: MacL10n.string("mac.onboarding.microphone.granted", language: lang),
neededText: MacL10n.string("mac.onboarding.microphone.needed", language: lang)
)
case .accessibility:
permissionCard(
isGranted: model.accessibilityTrusted,
grantedText: MacL10n.string("mac.onboarding.accessibility.granted", language: lang),
neededText: MacL10n.string("mac.onboarding.accessibility.needed", language: lang)
)
case .engine:
enginePicker
case .cloudAPI:
cloudAPIFields
case .localModel:
localModelPanel
}
}
private var featureList: some View {
VStack(spacing: Spacing.sm) {
featureRow("lock.shield.fill", MacL10n.string("mac.onboarding.welcome.privacy", language: lang))
featureRow("option", MacL10n.string("mac.onboarding.welcome.hotkey", language: lang))
featureRow("cpu", MacL10n.string("mac.onboarding.welcome.local", language: lang))
}
}
private func featureRow(_ icon: String, _ text: String) -> some View {
HStack(spacing: Spacing.md) {
Image(systemName: icon)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(palette.accent)
.frame(width: 26, height: 26)
.background(palette.accentMuted, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
Text(text)
.font(TypeStyle.footnote)
.foregroundStyle(palette.textPrimary)
.multilineTextAlignment(.leading)
Spacer(minLength: 0)
}
.padding(.vertical, Spacing.xs)
.padding(.horizontal, Spacing.md)
.frame(maxWidth: .infinity)
.background(cardShape.fill(palette.surface))
.overlay(cardShape.stroke(palette.divider, lineWidth: 0.5))
}
private func permissionCard(isGranted: Bool, grantedText: String, neededText: String) -> some View {
HStack(spacing: Spacing.sm) {
Image(systemName: isGranted ? "checkmark.seal.fill" : "exclamationmark.circle.fill")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(isGranted ? palette.accent : palette.warning)
Text(isGranted ? grantedText : neededText)
.font(TypeStyle.bodyEmph)
.foregroundStyle(palette.textPrimary)
Spacer(minLength: 0)
}
.padding(Spacing.md)
.frame(maxWidth: .infinity)
.background(cardShape.fill(palette.surface))
.overlay(cardShape.stroke((isGranted ? palette.accent : palette.warning).opacity(0.25), lineWidth: 1))
}
private var enginePicker: some View {
VStack(spacing: Spacing.sm) {
engineRow(
title: MacL10n.string("mac.settings.localEngine", language: lang),
subtitle: MacL10n.string("mac.onboarding.engine.localDesc", language: lang),
systemImage: "cpu",
selected: viewModel.config.engineMode == "local"
) { setEngine("local") }
engineRow(
title: MacL10n.string("mac.settings.cloudEngine", language: lang),
subtitle: MacL10n.string("mac.onboarding.engine.cloudDesc", language: lang),
systemImage: "cloud.fill",
selected: viewModel.config.engineMode == "cloud"
) { setEngine("cloud") }
}
}
private func engineRow(
title: String,
subtitle: String,
systemImage: String,
selected: Bool,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
HStack(spacing: Spacing.md) {
Image(systemName: systemImage)
.font(.system(size: 18, weight: .medium))
.foregroundStyle(selected ? palette.accent : palette.textTertiary)
.frame(width: 30)
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(TypeStyle.bodyEmph)
.foregroundStyle(palette.textPrimary)
Text(subtitle)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: Spacing.sm)
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
.font(.system(size: 18))
.foregroundStyle(selected ? palette.accent : palette.textTertiary.opacity(0.6))
}
.padding(Spacing.md)
.frame(maxWidth: .infinity)
.background(cardShape.fill(selected ? palette.accentMuted : palette.surface))
.overlay(cardShape.stroke(selected ? palette.accent.opacity(0.5) : palette.divider, lineWidth: selected ? 1 : 0.5))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
private var cloudAPIFields: some View {
VStack(alignment: .leading, spacing: Spacing.md) {
Picker(MacL10n.string("mac.settings.service", language: lang), selection: providerBinding) {
ForEach(viewModel.selectableProviders) { provider in
Text(provider.name).tag(provider.id)
}
}
.labelsHidden()
.frame(maxWidth: .infinity, alignment: .leading)
SecureField(text: $viewModel.config.apiKey, prompt: Text(verbatim: "sk-...")) {
Text(MacL10n.string("mac.settings.apiKey", language: lang))
}
.labelsHidden()
.macFieldStyle()
TextField(text: $viewModel.config.model, prompt: Text(verbatim: "")) {
Text(MacL10n.string("mac.settings.model", language: lang))
}
.labelsHidden()
.macFieldStyle()
Label(MacL10n.string("mac.onboarding.cloud.skipHint", language: lang), systemImage: "info.circle")
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
.padding(Spacing.md)
.frame(maxWidth: .infinity)
.background(cardShape.fill(palette.surface))
.overlay(cardShape.stroke(palette.divider, lineWidth: 0.5))
}
private var localModelPanel: some View {
VStack(alignment: .leading, spacing: Spacing.md) {
HStack(spacing: Spacing.sm) {
Image(systemName: model.isDefaultModelInstalled ? "checkmark.circle.fill" : "shippingbox.fill")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(model.isDefaultModelInstalled ? palette.accent : palette.textTertiary)
VStack(alignment: .leading, spacing: 2) {
Text(model.defaultModel?.displayName ?? MacL10n.string("mac.localASR.catalogMissing", language: lang))
.font(TypeStyle.bodyEmph)
.foregroundStyle(palette.textPrimary)
Text(localModelSubtitle)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
Spacer(minLength: Spacing.sm)
if !model.isDefaultModelInstalled, !model.isInstalling {
Button(MacL10n.string("mac.onboarding.model.download", language: lang)) {
model.installDefaultModel()
}
.buttonStyle(.borderedProminent)
.tint(palette.accent)
.disabled(model.defaultModel == nil)
}
}
if model.isInstalling || model.installProgress.phase != .idle {
ProgressView(value: model.installProgress.fraction)
.tint(palette.accent)
Text(model.progressLabel(language: lang))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
if !model.statusMessage.isEmpty {
Text(model.statusMessage)
.font(TypeStyle.caption)
.foregroundStyle(model.isDefaultModelInstalled ? palette.accent : palette.warning)
}
Label(MacL10n.string("mac.onboarding.model.skipHint", language: lang), systemImage: "info.circle")
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
.padding(Spacing.md)
.frame(maxWidth: .infinity)
.background(cardShape.fill(palette.surface))
.overlay(cardShape.stroke(palette.divider, lineWidth: 0.5))
}
// MARK: Progress dots
private var progressDots: some View {
HStack(spacing: 6) {
ForEach(Array(visibleSteps.enumerated()), id: \.offset) { index, _ in
Capsule()
.fill(index == currentStepIndex ? palette.accent : palette.textTertiary.opacity(0.28))
.frame(width: index == currentStepIndex ? 22 : 6, height: 6)
}
}
.animation(Motion.quick, value: currentStepIndex)
}
// MARK: Bottom bar
private var bottomBar: some View {
HStack(spacing: Spacing.sm) {
if canGoBack {
secondaryButton(MacL10n.string("mac.onboarding.back", language: lang)) { goBack() }
}
if canSkipCurrentStep {
secondaryButton(MacL10n.string("mac.onboarding.skipForNow", language: lang)) {
if isLastStep { finish() } else { goForward() }
}
}
Spacer(minLength: 0)
primaryButton(primaryButtonTitle, disabled: model.isInstalling && model.step == .localModel) {
primaryAction()
}
}
.frame(maxWidth: 520)
.frame(maxWidth: .infinity)
}
private func primaryButton(_ titleText: String, disabled: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(titleText)
.font(TypeStyle.headline)
.foregroundStyle(disabled ? palette.textSecondary : palette.textOnAccent)
.padding(.horizontal, Spacing.xxl)
.frame(minWidth: 150, minHeight: 44)
.background(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.fill(disabled ? palette.surfaceElevated : palette.accent)
)
}
.buttonStyle(.plain)
.disabled(disabled)
}
private func secondaryButton(_ titleText: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(titleText)
.font(TypeStyle.bodyEmph)
.foregroundStyle(palette.textSecondary)
.padding(.horizontal, Spacing.lg)
.frame(minHeight: 44)
.background(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.dividerStrong, lineWidth: 0.5)
)
}
.buttonStyle(.plain)
}
// MARK: Copy
private var title: String {
switch model.step {
case .welcome: return MacL10n.string("mac.onboarding.welcome.title", language: lang)
case .microphone: return MacL10n.string("mac.onboarding.microphone.title", language: lang)
case .accessibility: return MacL10n.string("mac.onboarding.accessibility.title", language: lang)
case .engine: return MacL10n.string("mac.onboarding.engine.title", language: lang)
case .cloudAPI: return MacL10n.string("mac.onboarding.cloud.title", language: lang)
case .localModel: return MacL10n.string("mac.onboarding.model.title", language: lang)
}
}
private var subtitle: String {
switch model.step {
case .welcome: return MacL10n.string("mac.onboarding.welcome.subtitle", language: lang)
case .microphone: return MacL10n.string("mac.onboarding.microphone.subtitle", language: lang)
case .accessibility: return MacL10n.string("mac.onboarding.accessibility.subtitle", language: lang)
case .engine: return MacL10n.string("mac.onboarding.engine.subtitle", language: lang)
case .cloudAPI: return MacL10n.string("mac.onboarding.cloud.subtitle", language: lang)
case .localModel: return MacL10n.string("mac.onboarding.model.subtitle", language: lang)
}
}
private var primaryButtonTitle: String {
switch model.step {
case .microphone where model.micStatus != .authorized:
return MacL10n.string("mac.onboarding.microphone.allow", language: lang)
case .accessibility where !model.accessibilityTrusted:
return MacL10n.string("mac.onboarding.accessibility.open", language: lang)
case .cloudAPI:
return MacL10n.string("mac.onboarding.finish", language: lang)
case .localModel:
return MacL10n.string(model.isDefaultModelInstalled ? "mac.onboarding.finish" : "mac.onboarding.skipForNow", language: lang)
default:
return isLastStep ? MacL10n.string("mac.onboarding.finish", language: lang) : MacL10n.string("mac.onboarding.next", language: lang)
}
}
private var localModelSubtitle: String {
if model.isDefaultModelInstalled {
return MacL10n.string("mac.localASR.installed", language: lang)
}
guard let model = model.defaultModel else { return "" }
return ByteCountFormatter.string(fromByteCount: Int64(model.sizeBytes), countStyle: .file)
}
private var providerBinding: Binding<String> {
Binding(
get: { viewModel.config.providerId },
set: { newId in
guard let provider = viewModel.selectableProviders.first(where: { $0.id == newId }) else { return }
viewModel.selectProvider(provider)
}
)
}
// MARK: Derived
private var cardShape: RoundedRectangle {
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
}
private var stepTransition: AnyTransition {
.asymmetric(
insertion: .opacity.combined(with: .offset(y: 10)),
removal: .opacity.combined(with: .offset(y: -10))
)
}
private var currentStepIndex: Int {
visibleSteps.firstIndex(of: model.step) ?? 0
}
private var canGoBack: Bool {
currentStepIndex > 0 && !model.isInstalling
}
private var canSkipCurrentStep: Bool {
model.step != .welcome && !model.isInstalling
}
private var isLastStep: Bool {
currentStepIndex == visibleSteps.count - 1
}
// MARK: Actions
private func setEngine(_ mode: String) {
withAnimation(Motion.quick) { viewModel.setEngineMode(mode) }
}
private func primaryAction() {
switch model.step {
case .microphone where model.micStatus != .authorized:
model.requestMicrophone()
case .accessibility where !model.accessibilityTrusted:
model.openAccessibilitySettings()
default:
if isLastStep { finish() } else { goForward() }
}
}
private func goForward() {
let nextIndex = min(currentStepIndex + 1, visibleSteps.count - 1)
withAnimation(Motion.soft) { model.step = visibleSteps[nextIndex] }
}
private func goBack() {
let previousIndex = max(currentStepIndex - 1, 0)
withAnimation(Motion.soft) { model.step = visibleSteps[previousIndex] }
}
private func finish() {
viewModel.selectedSection = .dashboard
hasCompletedOnboarding = true
}
private func applyDefaults() {
guard !hasCompletedOnboarding else { return }
if viewModel.config.apiKey.isEmpty, viewModel.config.engineMode == "cloud" {
viewModel.setEngineMode("local")
}
}
}
+95
View File
@@ -0,0 +1,95 @@
// MacQwen3ASREngine.swift
// OSGKeyboard · Mac
//
// Singleton actor that loads, warms up, and runs Qwen3-ASR via mlx-swift-asr.
// Model load + Metal JIT warmup take several seconds call `prepareIfNeeded`
// at launch so the first dictation is fast.
import Foundation
import MLXASR
/// Lifecycle of the on-disk MLX model inside the app process.
enum MacQwen3EnginePhase: Sendable, Equatable {
case idle
case loading
case ready
case failed(String)
}
actor MacQwen3ASREngine {
static let shared = MacQwen3ASREngine()
private var stt: Qwen3ASRSTT?
private var loadedModelPath: String?
private(set) var phase: MacQwen3EnginePhase = .idle
private init() {}
/// Load and warm up the model when the path changes or nothing is loaded yet.
func prepareIfNeeded(modelPath: String) async throws {
if loadedModelPath == modelPath, stt != nil, phase == .ready { return }
phase = .loading
stt = nil
loadedModelPath = nil
let directory = URL(fileURLWithPath: modelPath, isDirectory: true)
do {
let instance = try await Qwen3ASRSTT.loadWithWarmup(from: directory)
stt = instance
loadedModelPath = modelPath
phase = .ready
} catch {
let detail = error.localizedDescription
phase = .failed(detail)
throw MacLocalASRError.qwen3LoadFailed(detail)
}
}
/// Transcribe mono 16 kHz float PCM. Ensures the model is loaded first.
func transcribe(
samples: [Float],
language: String?,
modelPath: String,
context: String? = nil
) async throws -> String {
try await prepareIfNeeded(modelPath: modelPath)
guard let stt else {
throw MacLocalASRError.qwen3LoadFailed("Engine not initialized")
}
let result = try await stt.transcribe(audio: samples, language: language, context: context)
let text = result.text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else {
throw MacLocalASRError.emptyTranscript
}
return text
}
/// Drop cached weights (e.g. after the user changes the model folder).
func unload() {
stt = nil
loadedModelPath = nil
phase = .idle
}
}
enum MacQwen3LanguageHint {
/// Map persisted BCP-47 locale ids to Qwen3 prompt language names.
/// Returns `nil` for auto-detect.
static func from(locale: Locale) -> String? {
let raw = locale.identifier.lowercased()
if raw.isEmpty || raw == "auto" { return nil }
if raw.hasPrefix("zh") { return "Chinese" }
if raw.hasPrefix("en") { return "English" }
if raw.hasPrefix("ja") { return "Japanese" }
if raw.hasPrefix("ko") { return "Korean" }
if raw.hasPrefix("fr") { return "French" }
if raw.hasPrefix("de") { return "German" }
if raw.hasPrefix("es") { return "Spanish" }
if raw.hasPrefix("pt") { return "Portuguese" }
if raw.hasPrefix("ru") { return "Russian" }
if raw.hasPrefix("ar") { return "Arabic" }
return nil
}
}
+58
View File
@@ -0,0 +1,58 @@
// MacQwen3LocalASR.swift
// OSGKeyboard · Mac
//
// Qwen3-ASR-1.7B (MLX) via mlx-swift-asr. Expects a converted model directory
// containing config.json, model.safetensors, and tokenizer files.
import Foundation
enum MacQwen3LocalASR {
/// Transcribe with Qwen3-ASR MLX weights at `modelPath`.
static func transcribe(
samples: [Float],
sampleRate: Int,
locale: Locale,
modelPath: String,
bias: LocalASRBiasPayload? = nil
) async throws -> String {
guard modelDirectoryIsInstalled(at: modelPath) else {
throw MacLocalASRError.qwen3ModelMissing
}
guard sampleRate == 16_000 else {
throw MacLocalASRError.qwen3InferenceFailed(
"Qwen3-ASR expects 16 kHz audio (got \(sampleRate) Hz)"
)
}
let language = MacQwen3LanguageHint.from(locale: locale)
let context = bias?.promptBias?.trimmingCharacters(in: .whitespacesAndNewlines)
let promptContext = (context?.isEmpty == false) ? context : nil
do {
return try await MacQwen3ASREngine.shared.transcribe(
samples: samples,
language: language,
modelPath: modelPath,
context: promptContext
)
} catch let error as MacLocalASRError {
throw error
} catch {
throw MacLocalASRError.qwen3InferenceFailed(error.localizedDescription)
}
}
private static func modelDirectoryIsInstalled(at path: String) -> Bool {
var isDir: ObjCBool = false
guard FileManager.default.fileExists(atPath: path, isDirectory: &isDir), isDir.boolValue else {
return false
}
let fm = FileManager.default
let config = (path as NSString).appendingPathComponent("config.json")
let weights = (path as NSString).appendingPathComponent("model.safetensors")
guard fm.fileExists(atPath: config), fm.fileExists(atPath: weights) else {
return false
}
let names = (try? fm.contentsOfDirectory(atPath: path)) ?? []
return names.contains("vocab.json") && names.contains("merges.txt")
}
}
+156
View File
@@ -0,0 +1,156 @@
// MacRootView.swift
// OSGKeyboard · Mac
//
// Window shell built on the native `NavigationSplitView` so the desktop app
// reads like macOS System Settings: traffic lights float over a borderless
// sidebar, no separate title bar. The same structure lifts cleanly onto
// iPadOS later (NavigationSplitView is cross-platform).
import SwiftUI
struct MacRootView: View {
@ObservedObject var viewModel: MacDictationViewModel
@Environment(\.themePalette) private var palette
@Environment(\.openWindow) private var openWindow
@State private var columnVisibility: NavigationSplitViewVisibility = .all
private var uiLanguage: AppUILanguage { viewModel.config.uiLanguage }
/// `List` selection is optional; keep the view model's non-optional section
/// in sync without letting a nil selection blank the detail pane.
private var selection: Binding<MacSection?> {
Binding(
get: { viewModel.selectedSection },
set: { if let new = $0 { viewModel.selectedSection = new } }
)
}
var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
sidebar
.navigationSplitViewColumnWidth(MacMetrics.sidebarWidth)
} detail: {
detail
}
.navigationSplitViewStyle(.balanced)
.frame(minWidth: 860, minHeight: 600)
.onAppear {
// Let the AppKit status-bar popover reopen this window on demand.
MacWindowBridge.shared.open = { openWindow(id: "main") }
}
}
// MARK: - Sidebar
private var sidebar: some View {
VStack(spacing: 0) {
brandHeader
VStack(spacing: 4) {
ForEach(MacSection.allCases) { section in
MacSidebarRow(
section: section,
isSelected: viewModel.selectedSection == section,
language: uiLanguage
) {
withAnimation(Motion.soft) { viewModel.selectedSection = section }
}
}
}
.padding(.horizontal, MacMetrics.sidebarInset)
Spacer()
devicesFooter
}
}
/// Brand mark pinned above the nav list. Top padding clears the traffic
/// lights that now float over the borderless sidebar.
private var brandHeader: some View {
HStack {
Image("OSGLogoWide")
.renderingMode(.template)
.resizable()
.scaledToFit()
.frame(height: 30)
.foregroundStyle(palette.accent)
.accessibilityLabel("OSGKeyboard")
Spacer()
}
.padding(.leading, MacMetrics.sidebarContentInset)
.padding(.trailing, MacMetrics.sidebarInset)
.padding(.top, Spacing.lg)
.padding(.bottom, Spacing.lg)
}
private var devicesFooter: some View {
Label(
MacL10n.string("mac.devices", language: uiLanguage),
systemImage: "laptopcomputer.and.iphone"
)
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, MacMetrics.sidebarInset)
.padding(.vertical, Spacing.sm)
}
// MARK: - Detail
private var detail: some View {
VStack(spacing: 0) {
Group {
switch viewModel.selectedSection {
case .dashboard: DashboardView(viewModel: viewModel)
case .history: MacHistoryView(viewModel: viewModel)
case .dictionary: MacDictionaryView(viewModel: viewModel)
case .settings: MacSettingsView(viewModel: viewModel)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.id(viewModel.selectedSection)
.transition(.opacity)
MacStatusFooter(viewModel: viewModel)
}
.background(palette.background)
}
}
// MARK: - Sidebar row
/// A navigation row with an animated hover highlight and selection state,
/// matching the macOS System Settings feel.
private struct MacSidebarRow: View {
let section: MacSection
let isSelected: Bool
let language: AppUILanguage
let action: () -> Void
@Environment(\.themePalette) private var palette
@State private var isHovering = false
var body: some View {
Button(action: action) {
Label(section.title(language: language), systemImage: section.systemImage)
.font(.system(size: 13))
.foregroundStyle(isSelected ? palette.textOnAccent : palette.textPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 7)
.background(
rowBackground,
in: RoundedRectangle(cornerRadius: 7, style: .continuous)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.animation(Motion.quick, value: isSelected)
.animation(Motion.quick, value: isHovering)
.onHover { isHovering = $0 }
}
private var rowBackground: Color {
if isSelected { return palette.accent }
return isHovering ? palette.textPrimary.opacity(0.06) : .clear
}
}
+405
View File
@@ -0,0 +1,405 @@
// MacSettingsView.swift
// OSGKeyboard · Mac
//
// Settings built on the native grouped `Form` the same container macOS
// System Settings uses. This gives system-accurate cards, dividers, insets
// and right-aligned controls for free, on both light and dark.
import SwiftUI
#if os(macOS)
import AppKit
#endif
struct MacSettingsView: View {
@ObservedObject var viewModel: MacDictationViewModel
@Environment(\.themePalette) private var palette
@AppStorage(MacAppearancePreference.storageKey)
private var appearanceRaw = MacAppearancePreference.system.rawValue
@AppStorage(MacOnboardingState.storageKey)
private var hasCompletedMacOnboarding = true
@State private var accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
@State private var showProviderPicker = false
private var lang: AppUILanguage { viewModel.config.uiLanguage }
private let recognitionLocales: [(id: String, key: String, fallback: String)] = [
("auto", "locale.auto", "Auto"),
("zh-Hans", "locale.zh-Hans", "Chinese (Simplified)"),
("zh-Hant", "locale.zh-Hant", "Chinese (Traditional)"),
("en-US", "locale.en-US", "English (US)"),
("ja-JP", "locale.ja-JP", "Japanese"),
("ko-KR", "locale.ko-KR", "Korean")
]
var body: some View {
NavigationStack {
Form {
generalSection
recognitionSection
if viewModel.config.engineMode == "cloud" {
providerSection
.transition(.opacity)
}
if viewModel.config.engineMode == "local" {
MacLocalASRModelSettingsView(viewModel: viewModel)
.transition(.opacity)
}
inputSection
legalSection
}
.formStyle(.grouped)
.tint(palette.accent)
.scrollContentBackground(.hidden)
.background(palette.background)
}
.onAppear { refreshAccessibilityState() }
}
// MARK: - General
private var generalSection: some View {
Section(MacL10n.string("mac.settings.general", language: lang)) {
Picker(MacL10n.string("mac.settings.appearance", language: lang), selection: $appearanceRaw) {
ForEach(MacAppearancePreference.allCases) { pref in
Text(MacL10n.string(pref.labelKey, language: lang)).tag(pref.rawValue)
}
}
Picker(MacL10n.string("mac.settings.interfaceLanguage", language: lang), selection: interfaceLanguageBinding) {
ForEach(AppUILanguage.allCases, id: \.self) { language in
Text(MacL10n.string(language.labelKey, language: lang)).tag(language.rawValue)
}
}
Picker(MacL10n.string("mac.settings.recognitionLanguage", language: lang), selection: recognitionLanguageBinding) {
ForEach(recognitionLocales, id: \.id) { locale in
Text(localeLabel(locale)).tag(locale.id)
}
}
MacSettingsICloudSyncRow(defaults: viewModel.defaults, language: lang)
MacDictionaryICloudSyncRow(defaults: viewModel.defaults, language: lang)
}
}
// MARK: - Cloud provider
private var providerSection: some View {
Section(MacL10n.string("mac.settings.cloudProvider", language: lang)) {
LabeledContent(MacL10n.string("mac.settings.service", language: lang)) {
Button {
showProviderPicker = true
} label: {
HStack(spacing: 6) {
providerLogo(currentProvider.id)
Text(currentProvider.name)
.foregroundStyle(palette.textPrimary)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.popover(isPresented: $showProviderPicker, arrowEdge: .bottom) {
providerPickerList
}
}
LabeledContent {
SecureField(text: $viewModel.config.apiKey, prompt: Text(verbatim: "sk-…")) {
Text(MacL10n.string("mac.settings.apiKey", language: lang))
}
.labelsHidden()
.macFieldStyle()
.frame(maxWidth: MacMetrics.controlWidth)
} label: {
Text(MacL10n.string("mac.settings.apiKey", language: lang))
}
LabeledContent {
TextField(text: $viewModel.config.model, prompt: Text(verbatim: "")) {
Text(MacL10n.string("mac.settings.model", language: lang))
}
.labelsHidden()
.macFieldStyle()
.frame(maxWidth: MacMetrics.controlWidth)
} label: {
Text(MacL10n.string("mac.settings.model", language: lang))
}
}
}
// MARK: - Recognition method
private var recognitionSection: some View {
Section(MacL10n.string("mac.settings.recognition", language: lang)) {
methodRow(
title: MacL10n.string("mac.settings.cloudEngine", language: lang),
subtitle: MacL10n.string("mac.settings.cloudEngineDesc", language: lang),
systemImage: "cloud",
selected: viewModel.config.engineMode == "cloud"
) { withAnimation(Motion.soft) { viewModel.setEngineMode("cloud") } }
methodRow(
title: MacL10n.string("mac.settings.localEngine", language: lang),
subtitle: MacL10n.string("mac.settings.localEngineDesc", language: lang),
systemImage: "cpu",
selected: viewModel.config.engineMode == "local"
) { withAnimation(Motion.soft) { viewModel.setEngineMode("local") } }
}
}
// MARK: - Hotkey / paste
private var inputSection: some View {
Section(MacL10n.string("mac.settings.input", language: lang)) {
Toggle(isOn: hotkeyBinding) {
rowLabel(
MacL10n.string("mac.settings.hotkey", language: lang),
subtitle: MacL10n.string("mac.settings.hotkeyDesc", language: lang)
)
}
Toggle(isOn: autoPasteBinding) {
rowLabel(
MacL10n.string("mac.settings.autoPaste", language: lang),
subtitle: MacL10n.string("mac.settings.autoPasteDesc", language: lang)
)
}
LabeledContent {
HStack(spacing: Spacing.sm) {
Label(
accessibilityTrusted ? accessibilityStatusGranted : accessibilityStatusNeeded,
systemImage: accessibilityTrusted ? "checkmark.circle.fill" : "exclamationmark.circle"
)
.font(TypeStyle.caption)
.foregroundStyle(accessibilityTrusted ? palette.accent : palette.warning)
.contentTransition(.opacity)
.animation(Motion.quick, value: accessibilityTrusted)
Button(MacL10n.string("mac.settings.openAccessibility", language: lang)) {
openAccessibilitySettings()
}
}
} label: {
rowLabel(
MacL10n.string("mac.settings.accessibility", language: lang),
subtitle: MacL10n.string("mac.settings.accessibilityDesc", language: lang)
)
}
}
}
// MARK: - Legal
private var legalSection: some View {
Section(MacL10n.string("mac.settings.about", language: lang)) {
NavigationLink {
MacPrivacyPolicyView(uiLanguage: lang)
} label: {
Text(MacL10n.string("mac.settings.privacyPolicy", language: lang))
}
NavigationLink {
MacOpenSourceLicensesView(uiLanguage: lang)
} label: {
Text(MacL10n.string("mac.settings.thirdPartyLicenses", language: lang))
}
Button(MacL10n.string("mac.settings.restartOnboarding", language: lang)) {
hasCompletedMacOnboarding = false
}
}
}
// MARK: - Row helpers
private func rowLabel(_ title: String, subtitle: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(title)
Text(subtitle)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
}
private func methodRow(
title: String,
subtitle: String,
systemImage: String,
selected: Bool,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
HStack(spacing: Spacing.sm) {
Image(systemName: systemImage)
.font(.system(size: 16, weight: .medium))
.foregroundStyle(selected ? palette.accent : palette.textTertiary)
.frame(width: 26)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.foregroundStyle(palette.textPrimary)
Text(subtitle)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: Spacing.sm)
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(selected ? palette.accent : palette.textTertiary)
.contentTransition(.symbolEffect(.replace))
}
.animation(Motion.quick, value: selected)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
private var currentProvider: LLMProvider {
viewModel.selectableProviders.first { $0.id == viewModel.config.providerId }
?? viewModel.selectableProviders.first
?? LLMProvider.presets[0]
}
/// Custom dropdown list shown in a popover. SwiftUI's `Menu` label / items
/// silently drop bundled (non-SF-Symbol) images on macOS, so we render the
/// brand marks in a plain view stack instead.
private var providerPickerList: some View {
VStack(spacing: 0) {
ForEach(viewModel.selectableProviders) { provider in
Button {
viewModel.selectProvider(provider)
showProviderPicker = false
} label: {
HStack(spacing: Spacing.sm) {
providerLogo(provider.id)
Text(provider.name)
.foregroundStyle(palette.textPrimary)
Spacer(minLength: Spacing.md)
if provider.id == currentProvider.id {
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(palette.accent)
}
}
.padding(.horizontal, Spacing.md)
.frame(height: 34)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
.padding(.vertical, Spacing.xs)
.frame(width: 260)
}
/// Brand mark tinted to the current label colour (black on light / white
/// on dark). Template rendering + an explicit frame make the vector assets
/// resolve at a text-matched size inside the pop-up menu without a size
/// hint they collapse to zero and disappear.
@ViewBuilder
private func providerLogo(_ providerId: String) -> some View {
if let asset = ProviderLogo.assetName(for: providerId) {
Image(asset)
.resizable()
.scaledToFit()
.frame(width: 16, height: 16)
.foregroundStyle(palette.textPrimary)
}
}
@ViewBuilder
private func providerLabel(_ provider: LLMProvider) -> some View {
Label {
Text(provider.name)
} icon: {
providerLogo(provider.id)
}
}
// MARK: - Bindings
private var interfaceLanguageBinding: Binding<String> {
Binding(
get: { viewModel.config.uiLanguage.rawValue },
set: { viewModel.config.uiLanguage = AppUILanguage(rawValue: $0) ?? .auto }
)
}
private var providerBinding: Binding<String> {
Binding(
get: { viewModel.config.providerId },
set: { newId in
if let provider = viewModel.selectableProviders.first(where: { $0.id == newId }) {
viewModel.selectProvider(provider)
}
}
)
}
private var recognitionLanguageBinding: Binding<String> {
Binding(
get: {
let current = viewModel.config.localeId
return current.isEmpty ? "auto" : current
},
set: { newValue in
viewModel.config.localeId = newValue == "auto" ? "" : newValue
}
)
}
private var hotkeyBinding: Binding<Bool> {
Binding(
get: { viewModel.hotkeyEnabled },
set: { viewModel.setHotkeyEnabled($0) }
)
}
private var autoPasteBinding: Binding<Bool> {
Binding(
get: { viewModel.autoPasteEnabled },
set: { viewModel.setAutoPasteEnabled($0) }
)
}
// MARK: - AppKit actions (macOS only)
private func openAccessibilitySettings() {
#if os(macOS)
_ = MacTextInsertionService.requestAccessibilityIfNeeded()
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
NSWorkspace.shared.open(url)
}
refreshAccessibilityState()
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
refreshAccessibilityState()
}
#endif
}
private func refreshAccessibilityState() {
accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
}
private func localeLabel(_ locale: (id: String, key: String, fallback: String)) -> String {
let resolved = AppUILanguage.localizedString(
locale.key,
tableName: nil,
bundle: .main,
language: lang
)
return resolved == locale.key ? locale.fallback : resolved
}
private var accessibilityStatusGranted: String {
lang.resolvedLanguageCode().hasPrefix("zh") ? "已授权" : "Granted"
}
private var accessibilityStatusNeeded: String {
lang.resolvedLanguageCode().hasPrefix("zh") ? "未授权" : "Needed"
}
}
+64
View File
@@ -0,0 +1,64 @@
// MacSherpaLocalASR.swift
// OSGKeyboard · Mac
//
// Sherpa-onnx backed local ASR (Qwen3 hotwords POC + SenseVoice baseline).
import Foundation
enum MacSherpaLocalASR {
static func transcribe(
samples: [Float],
sampleRate: Int,
locale: Locale,
model: LocalASRModelDefinition,
bias: LocalASRBiasPayload?
) async throws -> String {
let catalog = try LocalASRModelCatalog.loadBundled()
let manager = LocalASRModelManager.shared
guard let layout = model.layout,
let modelRoot = LocalASRModelInstallState.modelRootURL(model) else {
throw MacLocalASRError.qwen3ModelMissing
}
try await manager.ensureRuntimeInstalled(catalog: catalog)
guard let runtime = LocalASRModelCatalog.runtime(
for: LocalASRModelCatalog.currentRuntimePlatform(),
in: catalog
),
let binary = LocalASRModelInstallState.resolveRuntimeBinary(runtime: runtime) else {
throw MacLocalASRError.qwen3LoadFailed("Sherpa runtime binary missing")
}
switch model.backend {
case .sherpaQwen3:
return try await MacSherpaONNXRunner.transcribeQwen3(
samples: samples,
sampleRate: sampleRate,
locale: locale,
modelRoot: modelRoot,
layout: layout,
runtimeBinary: binary,
bias: bias
)
case .sherpaSenseVoice:
return try await MacSherpaONNXRunner.transcribeSenseVoice(
samples: samples,
sampleRate: sampleRate,
modelRoot: modelRoot,
layout: layout,
runtimeBinary: binary
)
case .sherpaParaformer:
return try await MacSherpaONNXRunner.transcribeParaformer(
samples: samples,
sampleRate: sampleRate,
modelRoot: modelRoot,
layout: layout,
runtimeBinary: binary
)
default:
throw MacLocalASRError.qwen3InferenceFailed("Unsupported Sherpa backend")
}
}
}
+180
View File
@@ -0,0 +1,180 @@
// MacSherpaONNXRunner.swift
// OSGKeyboard · Mac
//
// Invokes the downloaded `sherpa-onnx-offline` binary for Sherpa-backed POC models.
import Foundation
enum MacSherpaONNXRunner {
static func transcribeQwen3(
samples: [Float],
sampleRate: Int,
locale: Locale,
modelRoot: URL,
layout: LocalASRModelLayout,
runtimeBinary: URL,
bias: LocalASRBiasPayload?
) async throws -> String {
guard sampleRate == 16_000 else {
throw MacLocalASRError.qwen3InferenceFailed("Sherpa expects 16 kHz audio")
}
guard let conv = layout.convFrontend,
let encoder = layout.encoder,
let decoder = layout.decoder,
let tokenizer = layout.tokenizer else {
throw MacLocalASRError.qwen3InferenceFailed("Incomplete Sherpa Qwen3 layout")
}
let wavURL = try writeTemporaryWAV(samples: samples, sampleRate: sampleRate)
defer { try? FileManager.default.removeItem(at: wavURL) }
var arguments = [
"--qwen3-asr-conv-frontend=\(modelRoot.appendingPathComponent(conv).path)",
"--qwen3-asr-encoder=\(modelRoot.appendingPathComponent(encoder).path)",
"--qwen3-asr-decoder=\(modelRoot.appendingPathComponent(decoder).path)",
"--qwen3-asr-tokenizer=\(modelRoot.appendingPathComponent(tokenizer).path)",
"--qwen3-asr-max-new-tokens=512",
"--num-threads=2",
]
if let language = MacQwen3LanguageHint.from(locale: locale) {
arguments.append("--qwen3-asr-language=\(language)")
}
if let hotwords = bias?.hardHotwords, !hotwords.isEmpty {
arguments.append("--qwen3-asr-hotwords=\(hotwords.joined(separator: ","))")
}
arguments.append(wavURL.path)
return try await run(binary: runtimeBinary, arguments: arguments)
}
static func transcribeSenseVoice(
samples: [Float],
sampleRate: Int,
modelRoot: URL,
layout: LocalASRModelLayout,
runtimeBinary: URL
) async throws -> String {
guard sampleRate == 16_000 else {
throw MacLocalASRError.qwen3InferenceFailed("Sherpa expects 16 kHz audio")
}
guard let model = layout.senseVoiceModel,
let tokens = layout.tokens else {
throw MacLocalASRError.qwen3InferenceFailed("Incomplete SenseVoice layout")
}
let wavURL = try writeTemporaryWAV(samples: samples, sampleRate: sampleRate)
defer { try? FileManager.default.removeItem(at: wavURL) }
let arguments = [
"--tokens=\(modelRoot.appendingPathComponent(tokens).path)",
"--sense-voice-model=\(modelRoot.appendingPathComponent(model).path)",
"--num-threads=2",
wavURL.path,
]
return try await run(binary: runtimeBinary, arguments: arguments)
}
static func transcribeParaformer(
samples: [Float],
sampleRate: Int,
modelRoot: URL,
layout: LocalASRModelLayout,
runtimeBinary: URL
) async throws -> String {
guard sampleRate == 16_000 else {
throw MacLocalASRError.qwen3InferenceFailed("Sherpa expects 16 kHz audio")
}
guard let paraformer = layout.paraformerModel,
let tokens = layout.tokens else {
throw MacLocalASRError.qwen3InferenceFailed("Incomplete Paraformer layout")
}
let wavURL = try writeTemporaryWAV(samples: samples, sampleRate: sampleRate)
defer { try? FileManager.default.removeItem(at: wavURL) }
let arguments = [
"--tokens=\(modelRoot.appendingPathComponent(tokens).path)",
"--paraformer=\(modelRoot.appendingPathComponent(paraformer).path)",
"--num-threads=2",
wavURL.path,
]
return try await run(binary: runtimeBinary, arguments: arguments)
}
// MARK: - Private
private static func writeTemporaryWAV(samples: [Float], sampleRate: Int) throws -> URL {
let data = PCMSampleWavEncoder.encode(samples: samples, sampleRate: sampleRate)
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("osg-sherpa-\(UUID().uuidString).wav")
try data.write(to: url, options: .atomic)
return url
}
private static func run(binary: URL, arguments: [String]) async throws -> String {
try await withCheckedThrowingContinuation { continuation in
let process = Process()
process.executableURL = binary
process.arguments = arguments
process.currentDirectoryURL = binary.deletingLastPathComponent()
let outputPipe = Pipe()
let errorPipe = Pipe()
process.standardOutput = outputPipe
process.standardError = errorPipe
process.terminationHandler = { proc in
let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
let stdout = String(data: outputData, encoding: .utf8) ?? ""
let stderr = String(data: errorData, encoding: .utf8) ?? ""
guard proc.terminationStatus == 0 else {
let detail = stderr.isEmpty ? stdout : stderr
continuation.resume(
throwing: MacLocalASRError.qwen3InferenceFailed(
detail.trimmingCharacters(in: .whitespacesAndNewlines)
)
)
return
}
let text = parseTranscript(stdout: stdout)
if text.isEmpty {
continuation.resume(throwing: MacLocalASRError.emptyTranscript)
} else {
continuation.resume(returning: text)
}
}
do {
try process.run()
} catch {
continuation.resume(throwing: MacLocalASRError.qwen3InferenceFailed(error.localizedDescription))
}
}
}
private static func parseTranscript(stdout: String) -> String {
let lines = stdout
.split(whereSeparator: \.isNewline)
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
for line in lines.reversed() {
if line.hasPrefix("{"), let data = line.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let text = object["text"] as? String {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return trimmed }
}
if !line.hasPrefix("/"), !line.hasPrefix("--"), line.count > 1 {
return line
}
}
return ""
}
}
+61
View File
@@ -0,0 +1,61 @@
// MacSpeechLocalASR.swift
// OSGKeyboard · Mac
//
// Apple Speech framework fallback for local engine mode. Writes PCM to a
// temp WAV and runs `SFSpeechURLRecognitionRequest`.
import AVFoundation
import Foundation
import Speech
enum MacSpeechLocalASR {
static func transcribe(samples: [Float], locale: Locale) async throws -> String {
let auth = await requestAuthorization()
guard auth == .authorized else { throw MacLocalASRError.speechDenied }
let wavURL = try writeTemporaryWAV(samples: samples, sampleRate: 16_000)
defer { try? FileManager.default.removeItem(at: wavURL) }
let recognizer = SFSpeechRecognizer(locale: locale) ?? SFSpeechRecognizer()
guard let recognizer, recognizer.isAvailable else {
throw MacLocalASRError.speechFailed("Speech recognizer unavailable")
}
return try await withCheckedThrowingContinuation { continuation in
let request = SFSpeechURLRecognitionRequest(url: wavURL)
request.shouldReportPartialResults = false
request.requiresOnDeviceRecognition = true
recognizer.recognitionTask(with: request) { result, error in
if let error {
continuation.resume(throwing: MacLocalASRError.speechFailed(error.localizedDescription))
return
}
guard let result, result.isFinal else { return }
let text = result.bestTranscription.formattedString
.trimmingCharacters(in: .whitespacesAndNewlines)
if text.isEmpty {
continuation.resume(throwing: MacLocalASRError.emptyTranscript)
} else {
continuation.resume(returning: text)
}
}
}
}
private static func requestAuthorization() async -> SFSpeechRecognizerAuthorizationStatus {
await withCheckedContinuation { continuation in
SFSpeechRecognizer.requestAuthorization { status in
continuation.resume(returning: status)
}
}
}
private static func writeTemporaryWAV(samples: [Float], sampleRate: Int) throws -> URL {
let wav = PCMSampleWavEncoder.encode(samples: samples, sampleRate: sampleRate)
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("osg-mac-asr-\(UUID().uuidString).wav")
try wav.write(to: url)
return url
}
}
@@ -0,0 +1,61 @@
// MacTextInsertionService.swift
// OSGKeyboard · Mac
//
// Inserts transcribed text into the frontmost app: clipboard first, then
// a synthetic V (SayIt / Typeless-style). Requires Accessibility trust.
import AppKit
@preconcurrency import ApplicationServices
import Carbon
import Foundation
enum MacTextInsertionService {
enum InsertionError: Error, LocalizedError {
case accessibilityNotGranted
var errorDescription: String? {
MacL10n.string("mac.error.accessibilityRequired")
}
}
static var isAccessibilityTrusted: Bool {
AXIsProcessTrusted()
}
@discardableResult
static func requestAccessibilityIfNeeded() -> Bool {
if AXIsProcessTrusted() { return true }
let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
let options = [promptKey: true] as CFDictionary
return AXIsProcessTrustedWithOptions(options)
}
/// Copy to pasteboard and optionally simulate V in the front app.
static func insert(
_ text: String,
autoPaste: Bool
) throws -> Bool {
guard !text.isEmpty else { return false }
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(text, forType: .string)
guard autoPaste else { return false }
guard AXIsProcessTrusted() else { throw InsertionError.accessibilityNotGranted }
Thread.sleep(forTimeInterval: 0.08)
postCommandV()
return true
}
private static func postCommandV() {
let source = CGEventSource(stateID: .combinedSessionState)
let keyCode = CGKeyCode(kVK_ANSI_V)
let keyDown = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true)
let keyUp = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false)
keyDown?.flags = CGEventFlags.maskCommand
keyUp?.flags = CGEventFlags.maskCommand
keyDown?.post(tap: CGEventTapLocation.cghidEventTap)
keyUp?.post(tap: CGEventTapLocation.cghidEventTap)
}
}
+110
View File
@@ -0,0 +1,110 @@
// MacTheme.swift
// OSGKeyboard · Mac
//
// System-native colour palette for the desktop app. Instead of the custom
// near-black brand palette, the Mac app maps every design token onto AppKit
// semantic colours, resolved to a concrete value for the *active* appearance.
// The brand green is kept only as the accent. Light mode uses a warm,
// iOS-matched surface set (the default `windowBackgroundColor` reads cold
// grey on macOS); Dark mode keeps the native AppKit semantic colours.
import AppKit
import SwiftUI
enum MacSystemPalette {
/// Returns the palette for the given colour scheme. Because the two
/// palettes hold *concrete* (already-resolved) colours, the value changes
/// identity when the scheme flips so injecting it via `@Environment`
/// (see `macSystemPalette()`) reliably re-renders every dependent view the
/// instant the appearance changes, instead of lagging until the next
/// view rebuild.
static func palette(for scheme: ColorScheme) -> ThemePalette {
scheme == .dark ? darkPalette : lightPalette
}
private static let lightPalette = makePalette(dark: false)
private static let darkPalette = makePalette(dark: true)
private static func makePalette(dark: Bool) -> ThemePalette {
ThemePalette(
background: resolved(dark ? darkBackground : warmBackground, dark: dark),
surface: resolved(dark ? darkSurface : warmSurface, dark: dark),
surfaceElevated: resolved(dark ? darkElevated : warmElevated, dark: dark),
surfaceMuted: resolved(dark ? darkMuted : warmMuted, dark: dark),
accent: Palette.accent,
accentMuted: Palette.accent.opacity(0.16),
accentGlow: Palette.accent.opacity(0.35),
danger: resolved(.systemRed, dark: dark),
success: Palette.accent,
warning: resolved(.systemOrange, dark: dark),
textPrimary: resolved(.labelColor, dark: dark),
textSecondary: resolved(.secondaryLabelColor, dark: dark),
textTertiary: resolved(.tertiaryLabelColor, dark: dark),
textOnAccent: Color.white,
divider: resolved(.separatorColor, dark: dark),
dividerStrong: resolved(.separatorColor, dark: dark),
recordRed: resolved(.systemRed, dark: dark)
)
}
// MARK: - Warm Light-mode surfaces (matched to iOS `Palette.light`)
/// #F2F1EE warm gray page background.
private static let warmBackground = NSColor(srgbRed: 0.949, green: 0.945, blue: 0.933, alpha: 1)
/// #FCFBF9 warm off-white card/control surface.
private static let warmSurface = NSColor(srgbRed: 0.988, green: 0.984, blue: 0.976, alpha: 1)
/// #EBEAE7 slightly recessed elevated surface.
private static let warmElevated = NSColor(srgbRed: 0.922, green: 0.918, blue: 0.906, alpha: 1)
/// #EEEDE9 muted fill between background and surface.
private static let warmMuted = NSColor(srgbRed: 0.933, green: 0.929, blue: 0.918, alpha: 1)
// MARK: - Dark-mode surfaces (Apple standard elevated grays)
//
// AppKit's `controlBackgroundColor` is *darker* than `windowBackgroundColor`
// in Dark Aqua, so cards using it recede into the page. Instead we step the
// surfaces explicitly (systemGray64 equivalents) so every card reads as
// clearly elevated above the background mirroring the iOS dark palette.
/// #1C1C1E page background.
private static let darkBackground = NSColor(srgbRed: 0.110, green: 0.110, blue: 0.118, alpha: 1)
/// #2C2C2E card / control surface, clearly lighter than the background.
private static let darkSurface = NSColor(srgbRed: 0.173, green: 0.173, blue: 0.180, alpha: 1)
/// #3A3A3C elevated fill for selected / raised chrome.
private static let darkElevated = NSColor(srgbRed: 0.227, green: 0.227, blue: 0.235, alpha: 1)
/// #242426 muted fill between background and surface.
private static let darkMuted = NSColor(srgbRed: 0.141, green: 0.141, blue: 0.149, alpha: 1)
/// Resolves a (possibly dynamic) AppKit colour to its concrete value under
/// the requested appearance, so the two static palettes differ by value.
private static func resolved(_ nsColor: NSColor, dark: Bool) -> Color {
guard let appearance = NSAppearance(named: dark ? .darkAqua : .aqua) else {
return Color(nsColor: nsColor)
}
var result = nsColor
appearance.performAsCurrentDrawingAppearance {
result = nsColor.usingColorSpace(.sRGB) ?? nsColor
}
return Color(nsColor: result)
}
}
private struct MacSystemPaletteModifier: ViewModifier {
@Environment(\.colorScheme) private var colorScheme
func body(content: Content) -> some View {
content.environment(\.themePalette, MacSystemPalette.palette(for: colorScheme))
}
}
extension View {
/// Injects the system-native palette used across the macOS app, refreshed
/// automatically whenever the effective colour scheme changes.
func macSystemPalette() -> some View {
modifier(MacSystemPaletteModifier())
}
}
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)com.osgkeyboard.ios</string>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.osgkeyboard.shared</string>
</array>
</dict>
</plist>
+227
View File
@@ -0,0 +1,227 @@
// OSGKeyboardMacApp.swift
// OSGKeyboard · Mac
//
// Entry point. A borderless, System-Settings-style main window plus a
// rock-solid AppKit status-bar item (NSStatusItem) with a dictation popover.
// Light / dark follows the user's Appearance preference (Settings General).
import AppKit
import SwiftUI
@main
struct OSGKeyboardMacApp: App {
@NSApplicationDelegateAdaptor(MacAppDelegate.self) private var appDelegate
@StateObject private var viewModel = MacDictationViewModel.shared
// Mac-local appearance preference. Drives both the SwiftUI colour scheme
// and via `applyToApp` the AppKit window chrome / popover.
@AppStorage(MacAppearancePreference.storageKey) private var appearanceRaw = MacAppearancePreference.system.rawValue
@AppStorage(MacOnboardingState.storageKey) private var hasCompletedMacOnboarding = false
private var appearance: MacAppearancePreference {
MacAppearancePreference(rawValue: appearanceRaw) ?? .system
}
var body: some Scene {
Window("OSGKeyboard", id: "main") {
Group {
if hasCompletedMacOnboarding {
MacRootView(viewModel: viewModel)
} else {
MacOnboardingView(
viewModel: viewModel,
hasCompletedOnboarding: $hasCompletedMacOnboarding
)
}
}
.macSystemPalette()
.environment(\.locale, viewModel.config.uiLanguage.swiftUILocale)
.preferredColorScheme(appearance.colorScheme)
.task { await viewModel.onAppear() }
.onAppear { MacAppearancePreference.applyToApp(appearance) }
.onChange(of: appearanceRaw) { MacAppearancePreference.applyToApp(appearance) }
.onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in
viewModel.reloadConfigFromCloud()
}
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
viewModel.refreshDictionaryFromCloud()
}
.onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in
viewModel.usageStatistics.reloadFromDisk()
}
.onReceive(NotificationCenter.default.publisher(for: .speechHistoryDidSyncFromCloud)) { _ in
viewModel.speechHistory.reloadFromDisk()
}
}
// Borderless titlebar content (sidebar + traffic lights) runs to the
// very top, matching macOS System Settings.
.windowStyle(.hiddenTitleBar)
.windowResizability(.contentMinSize)
.defaultSize(width: 1_024, height: 720)
}
}
// MARK: - Reopening the main window from AppKit
/// Bridges SwiftUI's `openWindow` action out to AppKit code (the status-bar
/// popover) that has no access to the scene environment.
@MainActor
final class MacWindowBridge {
static let shared = MacWindowBridge()
var open: (() -> Void)?
private init() {}
}
@MainActor
enum MacMainWindow {
/// Bring the app forward and show the main window, recreating it if the
/// user had closed it.
static func open() {
NSApp.activate(ignoringOtherApps: true)
MacWindowBridge.shared.open?()
}
}
// MARK: - Status-bar item (AppKit)
/// Owns the menu-bar `NSStatusItem` and its dictation popover. Implemented in
/// AppKit rather than SwiftUI's `MenuBarExtra` because the latter is flaky
/// when combined with a primary `Window` scene (the icon can silently vanish).
@MainActor
final class MacAppDelegate: NSObject, NSApplicationDelegate {
private var statusItem: NSStatusItem?
private let popover = NSPopover()
func applicationDidFinishLaunching(_ notification: Notification) {
MacAppearancePreference.applyToApp(.current)
configurePopover()
configureStatusItem()
// The menu bar always follows the *system* appearance, so the status
// item must ignore the app's forced light/dark override. Re-pin the
// button appearance whenever the system theme flips.
DistributedNotificationCenter.default.addObserver(
self,
selector: #selector(systemAppearanceDidChange),
name: NSNotification.Name("AppleInterfaceThemeChangedNotification"),
object: nil
)
}
deinit {
DistributedNotificationCenter.default.removeObserver(self)
}
/// Keep the app alive after the last window closes it lives in the menu bar.
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
false
}
private func configureStatusItem() {
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = item.button {
button.image = Self.makeStatusBarImage()
button.image?.accessibilityDescription = "OSGKeyboard"
button.action = #selector(togglePopover(_:))
button.target = self
}
statusItem = item
applyStatusItemAppearance()
}
/// Builds the menu-bar glyph from the dedicated horizontal status mark.
/// Height is pinned to the tallest practical menu-bar slot so the logo reads
/// clearly; width follows the asset's aspect ratio.
private static func makeStatusBarImage() -> NSImage? {
guard let image = NSImage(named: "OSGStatusMark")
?? NSImage(named: "OSGBrandMark")
?? NSImage(systemSymbolName: "mic.circle.fill", accessibilityDescription: "OSGKeyboard") else {
return nil
}
let height: CGFloat = 9
let aspect = max(image.size.width / max(image.size.height, 1), 1)
image.size = NSSize(width: height * aspect, height: height)
image.isTemplate = true
return image
}
/// Pins the status-bar button to the current *system* appearance so its
/// template image tint matches the real menu-bar background regardless of
/// the in-app light/dark preference forced on `NSApp.appearance`.
private func applyStatusItemAppearance() {
guard let button = statusItem?.button else { return }
let isDark = UserDefaults.standard.string(forKey: "AppleInterfaceStyle")?
.lowercased().contains("dark") ?? false
button.appearance = NSAppearance(named: isDark ? .darkAqua : .aqua)
}
@objc private func systemAppearanceDidChange() {
// The global-domain default lags the notification by a hair; hop to the
// next runloop tick so `AppleInterfaceStyle` reflects the new value.
DispatchQueue.main.async { [weak self] in
self?.applyStatusItemAppearance()
}
}
private func configurePopover() {
popover.behavior = .transient
popover.animates = true
popover.contentSize = NSSize(width: 340, height: 420)
popover.contentViewController = NSHostingController(rootView: MacMenuBarPopover())
}
@objc private func togglePopover(_ sender: Any?) {
guard let button = statusItem?.button else { return }
if popover.isShown {
popover.performClose(sender)
} else {
NSApp.activate(ignoringOtherApps: true)
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
popover.contentViewController?.view.window?.makeKey()
}
}
}
/// SwiftUI content hosted inside the status-bar popover. Shares the single
/// view model and follows the same appearance preference as the main window.
private struct MacMenuBarPopover: View {
@ObservedObject private var viewModel = MacDictationViewModel.shared
@AppStorage(MacAppearancePreference.storageKey) private var appearanceRaw = MacAppearancePreference.system.rawValue
@AppStorage(MacOnboardingState.storageKey) private var hasCompletedMacOnboarding = false
var body: some View {
Group {
if hasCompletedMacOnboarding {
MacContentView(viewModel: viewModel)
} else {
onboardingPrompt
}
}
.frame(width: 340)
.macSystemPalette()
.environment(\.locale, viewModel.config.uiLanguage.swiftUILocale)
.preferredColorScheme(MacAppearancePreference(rawValue: appearanceRaw)?.colorScheme ?? nil)
}
private var onboardingPrompt: some View {
VStack(spacing: Spacing.md) {
Image(systemName: "sparkles")
.font(.system(size: 30, weight: .semibold))
.foregroundStyle(.accent)
Text(MacL10n.string("mac.onboarding.popover.title", language: viewModel.config.uiLanguage))
.font(TypeStyle.headline)
Text(MacL10n.string("mac.onboarding.popover.subtitle", language: viewModel.config.uiLanguage))
.font(TypeStyle.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button(MacL10n.string("mac.openWindow", language: viewModel.config.uiLanguage)) {
MacMainWindow.open()
}
.buttonStyle(.borderedProminent)
}
.padding(Spacing.lg)
}
}
@@ -0,0 +1,10 @@
// AppGroupStore+ConfigurationStore.swift
// OSGKeyboard · Shared
//
// iOS / keyboard-extension configuration backed by the App Group suite.
import Foundation
extension AppGroupStore: ConfigurationStore {
public var cloudASRPersistence: UserDefaults { defaults }
}
@@ -0,0 +1,31 @@
// ConfigurationStore.swift
// OSGKeyboard · Shared
//
// Cross-platform read facade for the dictation pipeline (ASR polish).
// iOS implements this via `AppGroupStore` (App Group UserDefaults + Keychain).
// macOS will gain a separate implementation (standard UserDefaults + Keychain)
// without pulling in keyboard-extension-only APIs.
import Foundation
/// Read-only configuration surface consumed by ASR, cloud ASR, and polish services.
///
/// Keep this protocol narrow: only what the shared pipeline needs today.
/// Platform-specific settings UI and iCloud sync stay on concrete stores.
public protocol ConfigurationStore: Sendable {
var providerId: String { get }
var baseURL: String { get }
var apiKey: String { get }
var model: String { get }
var engineMode: String { get }
var polishIntensity: PolishIntensity { get }
var personalDictionary: PersonalDictionary { get }
/// Foreground-app context for polish prompts (keyboard extension publishes this).
var detectedAppContext: (context: AppContext, observedAt: Date)? { get }
/// Provider-specific ASR caches (e.g. Alibaba Fun-ASR vocabulary IDs).
var cloudASRPersistence: UserDefaults { get }
func makeClient() -> LLMClient
}
@@ -10,7 +10,10 @@ public struct RecordButton: View {
@Environment(\.themePalette) private var palette: ThemePalette
public enum Phase: Equatable {
case idle
/// Green host ready; tap records immediately.
case idleReady
/// Orange voice input unavailable (missing key, session not ready, etc.).
case idleUnavailable
case recording
case processing
case error
@@ -75,7 +78,7 @@ public struct RecordButton: View {
.animation(Motion.soft, value: level)
Circle()
.stroke(Color.white.opacity(phase == .idle ? 0.08 : 0.12), lineWidth: 0.5)
.stroke(Color.white.opacity(isIdle ? 0.08 : 0.12), lineWidth: 0.5)
.frame(width: Layout.outerRing, height: Layout.outerRing)
ZStack {
@@ -87,7 +90,7 @@ public struct RecordButton: View {
Group {
switch phase {
case .idle:
case .idleReady, .idleUnavailable:
Image(systemName: "mic.fill")
.font(.system(size: 36, weight: .medium))
.foregroundStyle(.white)
@@ -128,9 +131,9 @@ public struct RecordButton: View {
.animation(Motion.soft, value: remainingSeconds)
}
.contentShape(Circle())
.opacity(isEnabled ? 1 : 0.45)
.onTapGesture {
guard isEnabled, phase != .processing else { return }
guard phase != .processing else { return }
guard isEnabled || phase == .idleUnavailable else { return }
onToggle()
}
.onAppear { breath = (phase == .recording) }
@@ -140,6 +143,15 @@ public struct RecordButton: View {
.accessibilityLabel(Text(SharedL10n.string("keyboard.tapToTalkA11y")))
}
private var isIdle: Bool {
switch phase {
case .idleReady, .idleUnavailable:
return true
case .recording, .processing, .error:
return false
}
}
private func formatRemaining(_ seconds: Int) -> String {
let minutes = seconds / 60
let remainder = seconds % 60
@@ -159,13 +171,13 @@ public struct RecordButton: View {
startPoint: .top,
endPoint: .bottom
)
case .error:
case .error, .idleUnavailable:
return LinearGradient(
colors: [palette.warning.opacity(0.85), palette.warning.opacity(0.55)],
startPoint: .top,
endPoint: .bottom
)
case .idle:
case .idleReady:
return LinearGradient(
colors: [palette.accent.opacity(0.95), palette.accent.opacity(0.75)],
startPoint: .top,
@@ -36,6 +36,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public static let settingsICloudSyncEnabled = "config.settings.iCloudSyncEnabled"
/// Wall-clock stamp of the last settings blob applied from iCloud KVS.
public static let settingsCloudUpdatedAt = "config.settings.cloudUpdatedAt"
/// Cached per-field settings merge payload (`SyncedAppSettingsV2`).
public static let settingsCloudPayloadV2 = "config.settings.cloudPayload.v2"
/// When true, the host app auto-returns to the source app after a cold-start handoff.
public static let flowSkipAppSwitch = "config.flowSkipAppSwitch"
/// Raw `FlowInactivityDuration` value; session expires after this idle window.
@@ -104,8 +106,13 @@ public struct AppGroupConfiguration: Sendable, Equatable {
}
/// API key lives in the Keychain (cross-process, encrypted at rest).
/// When settings iCloud sync is on, reads synchronizable Keychain items first.
public var apiKey: String {
Keychain.apiKey(for: providerId) ?? ""
Self.resolveAPIKey(
defaults: nil,
providerId: providerId,
preferICloudSync: settingsICloudSyncEnabled
)
}
public func makeClient() -> LLMClient {
@@ -206,7 +213,11 @@ public struct AppGroupConfiguration: Sendable, Equatable {
}
// One-shot legacy migration: plaintext apiKey in UserDefaults Keychain.
_ = resolveAPIKey(defaults: defaults, providerId: config.providerId)
_ = resolveAPIKey(
defaults: defaults,
providerId: config.providerId,
preferICloudSync: config.settingsICloudSyncEnabled
)
// Cloud no longer exposes off/transcribe; migrate legacy values.
if config.engineMode == "cloud", config.modeId != "polish" {
@@ -295,19 +306,23 @@ public struct AppGroupConfiguration: Sendable, Equatable {
}
/// Read the API key from the Keychain, falling back to a one-time migration from UserDefaults.
static func resolveAPIKey(defaults: UserDefaults?, providerId: String) -> String {
if let stored = Keychain.apiKey(for: providerId), !stored.isEmpty {
static func resolveAPIKey(
defaults: UserDefaults?,
providerId: String,
preferICloudSync: Bool = false
) -> String {
if let stored = Keychain.apiKey(for: providerId, preferICloudSync: preferICloudSync), !stored.isEmpty {
return stored
}
if let legacyKeychain = Keychain.legacyAPIKey(), !legacyKeychain.isEmpty {
try? Keychain.setAPIKey(legacyKeychain, for: providerId)
try? Keychain.setAPIKey(legacyKeychain, for: providerId, useICloudSync: preferICloudSync)
try? Keychain.deleteLegacyAPIKey()
return legacyKeychain
}
if let defaults,
let legacy = defaults.string(forKey: Keys.apiKeyLegacy),
!legacy.isEmpty {
try? Keychain.setAPIKey(legacy, for: providerId)
try? Keychain.setAPIKey(legacy, for: providerId, useICloudSync: preferICloudSync)
defaults.removeObject(forKey: Keys.apiKeyLegacy)
return legacy
}
@@ -0,0 +1,99 @@
// LocalASRBiasPayload.swift
// OSGKeyboard · Shared
//
// Output of `LocalASRBiasAdapter` vocabulary signals for each pipeline layer.
import Foundation
public struct LocalASRCorrectionPair: Sendable, Equatable {
public let alias: String
public let term: String
public init(alias: String, term: String) {
self.alias = alias
self.term = term
}
}
public struct LocalASRBiasDiagnostics: Sendable, Equatable, Codable {
public var userTermCount: Int
public var builtinTermCount: Int
public var truncated: Bool
public var truncationReason: String?
public var selectedSources: [String]
public init(
userTermCount: Int = 0,
builtinTermCount: Int = 0,
truncated: Bool = false,
truncationReason: String? = nil,
selectedSources: [String] = []
) {
self.userTermCount = userTermCount
self.builtinTermCount = builtinTermCount
self.truncated = truncated
self.truncationReason = truncationReason
self.selectedSources = selectedSources
}
}
public struct LocalASRBiasPayload: Sendable, Equatable {
public var hardHotwords: [String]
public var promptBias: String?
public var corpusContext: String?
public var polishFragment: String
public var correctionPairs: [LocalASRCorrectionPair]
public var diagnostics: LocalASRBiasDiagnostics
public static let empty = LocalASRBiasPayload(
hardHotwords: [],
promptBias: nil,
corpusContext: nil,
polishFragment: "",
correctionPairs: [],
diagnostics: LocalASRBiasDiagnostics()
)
public init(
hardHotwords: [String],
promptBias: String?,
corpusContext: String?,
polishFragment: String,
correctionPairs: [LocalASRCorrectionPair],
diagnostics: LocalASRBiasDiagnostics
) {
self.hardHotwords = hardHotwords
self.promptBias = promptBias
self.corpusContext = corpusContext
self.polishFragment = polishFragment
self.correctionPairs = correctionPairs
self.diagnostics = diagnostics
}
}
public struct LocalASRBiasRequest: Sendable {
public var dictionary: PersonalDictionary
public var locale: Locale
public var frontAppBundleId: String?
public var capabilities: LocalASRCapabilities
/// Max builtin `phrases.tsv` terms considered for ASR bias (not polish-only).
public var builtinASRLimit: Int
/// Max builtin terms referenced in the polish supplement block.
public var builtinPolishLimit: Int
public init(
dictionary: PersonalDictionary,
locale: Locale,
frontAppBundleId: String? = nil,
capabilities: LocalASRCapabilities,
builtinASRLimit: Int = 300,
builtinPolishLimit: Int = 40
) {
self.dictionary = dictionary
self.locale = locale
self.frontAppBundleId = frontAppBundleId
self.capabilities = capabilities
self.builtinASRLimit = builtinASRLimit
self.builtinPolishLimit = builtinPolishLimit
}
}
@@ -0,0 +1,91 @@
// LocalASRCapabilities.swift
// OSGKeyboard · Shared
//
// Declares what each on-device ASR backend can accept for vocabulary bias.
// Callers must consult capabilities before building a `LocalASRBiasPayload`.
import Foundation
/// How a backend accepts vocabulary hints (honest matrix not every model
/// supports hard hotwords).
public enum LocalASRHotwordMode: String, Sendable, Codable, Equatable {
case none
case promptOnly
case perRequest
case recognizerScoped
case cloudVocabulary
}
/// Cost of refreshing hotwords on a backend (e.g. Sherpa Qwen3 reloads recognizer).
public enum LocalASRHotwordReloadCost: String, Sendable, Codable, Equatable {
case none
case recognizerReload
case modelReload
}
public struct LocalASRCapabilities: Sendable, Equatable {
public let hotwordMode: LocalASRHotwordMode
public let maxHotwordCount: Int
public let maxPromptCharacters: Int
public let supportsStreaming: Bool
public let hotwordReloadCost: LocalASRHotwordReloadCost
public init(
hotwordMode: LocalASRHotwordMode,
maxHotwordCount: Int,
maxPromptCharacters: Int,
supportsStreaming: Bool,
hotwordReloadCost: LocalASRHotwordReloadCost
) {
self.hotwordMode = hotwordMode
self.maxHotwordCount = maxHotwordCount
self.maxPromptCharacters = maxPromptCharacters
self.supportsStreaming = supportsStreaming
self.hotwordReloadCost = hotwordReloadCost
}
/// Qwen3 MLX via mlx-swift-asr `context` soft prompt on `transcribe`.
public static let qwen3MLX = LocalASRCapabilities(
hotwordMode: .promptOnly,
maxHotwordCount: 0,
maxPromptCharacters: 800,
supportsStreaming: false,
hotwordReloadCost: .none
)
/// Apple Speech on macOS no project-controlled hotword API today.
public static let appleSpeech = LocalASRCapabilities(
hotwordMode: .none,
maxHotwordCount: 0,
maxPromptCharacters: 0,
supportsStreaming: false,
hotwordReloadCost: .none
)
/// Sherpa Qwen3 hard hotwords via `--qwen3-asr-hotwords`.
public static let sherpaQwen3 = LocalASRCapabilities(
hotwordMode: .recognizerScoped,
maxHotwordCount: 100,
maxPromptCharacters: 0,
supportsStreaming: false,
hotwordReloadCost: .recognizerReload
)
/// Sherpa SenseVoice fast Chinese baseline without hotwords.
public static let sherpaSenseVoice = LocalASRCapabilities(
hotwordMode: .none,
maxHotwordCount: 0,
maxPromptCharacters: 0,
supportsStreaming: false,
hotwordReloadCost: .none
)
/// FunASR Paraformer (Sherpa offline) no project hotword API.
public static let sherpaParaformer = LocalASRCapabilities(
hotwordMode: .none,
maxHotwordCount: 0,
maxPromptCharacters: 0,
supportsStreaming: false,
hotwordReloadCost: .none
)
}
@@ -0,0 +1,195 @@
// LocalASRModelCatalog.swift
// OSGKeyboard · Shared
//
// Bundled catalog of downloadable / manual local ASR models and Sherpa runtimes.
import Foundation
public enum LocalASRModelBackend: String, Codable, Sendable, Equatable {
case mlx
case sherpaQwen3
case sherpaSenseVoice
case sherpaParaformer
case appleSpeech
}
public enum LocalASRInstallKind: String, Codable, Sendable, Equatable {
case manual
case archive
/// Multi-file install from a remote repository (ModelScope / HuggingFace file API).
case repository
case runtime
}
public struct LocalASRDownloadFile: Codable, Sendable, Equatable {
public let remotePath: String
public let localPath: String
public let sizeBytes: Int?
}
public struct LocalASRDownloadSource: Codable, Sendable, Equatable {
public let type: String
public let priority: Int
/// Full URL for a single archive download.
public let url: String
/// Base URL template for repository installs; must contain `{path}`.
public let baseURL: String?
public let files: [LocalASRDownloadFile]?
public var isRepository: Bool {
guard let files, !files.isEmpty else { return false }
return baseURL?.contains("{path}") == true
}
public var isArchive: Bool {
!url.isEmpty && !isRepository
}
}
public struct LocalASRModelLayout: Codable, Sendable, Equatable {
public var convFrontend: String?
public var encoder: String?
public var decoder: String?
public var tokenizer: String?
public var senseVoiceModel: String?
public var paraformerModel: String?
public var tokens: String?
}
public struct LocalASRRuntimeDefinition: Codable, Sendable, Equatable, Identifiable {
public let id: String
public let displayName: String
public let installRelativePath: String
public let binaryCandidates: [String]
public let archiveFileName: String
public let sizeBytes: Int
public let platform: String
public let sources: [LocalASRDownloadSource]
}
public struct LocalASRModelDefinition: Codable, Sendable, Equatable, Identifiable {
public let id: String
public let displayName: String
public let backend: LocalASRModelBackend
public let sizeBytes: Int
public let recommendedLocales: [String]
public let supportsHotwords: Bool
public let hotwordMode: LocalASRHotwordMode
public let installKind: LocalASRInstallKind
public let installRelativePath: String?
public let archiveBaseName: String?
public let layout: LocalASRModelLayout?
public let requiredRelativeFiles: [String]?
public let runtimePlatform: String?
public let sources: [LocalASRDownloadSource]?
}
public struct LocalASRCatalogDocument: Codable, Sendable, Equatable {
public let schemaVersion: Int
public let defaultModelId: String
public let runtimes: [LocalASRRuntimeDefinition]
public let models: [LocalASRModelDefinition]
}
public enum LocalASRModelCatalog {
public static func loadBundled() throws -> LocalASRCatalogDocument {
let bundle = Bundle(for: LocalASRCatalogBundleToken.self)
guard let url = bundle.url(forResource: "local-asr-catalog", withExtension: "json") else {
throw LocalASRModelCatalogError.missingBundledCatalog
}
let data = try Data(contentsOf: url)
return try JSONDecoder().decode(LocalASRCatalogDocument.self, from: data)
}
public static func model(_ id: String, in catalog: LocalASRCatalogDocument) -> LocalASRModelDefinition? {
catalog.models.first { $0.id == id }
}
public static func capabilities(for model: LocalASRModelDefinition) -> LocalASRCapabilities {
switch model.backend {
case .mlx:
return .qwen3MLX
case .sherpaQwen3:
return .sherpaQwen3
case .sherpaSenseVoice:
return .sherpaSenseVoice
case .sherpaParaformer:
return .sherpaParaformer
case .appleSpeech:
return .appleSpeech
}
}
#if os(macOS)
public static func runtime(for platform: String, in catalog: LocalASRCatalogDocument) -> LocalASRRuntimeDefinition? {
if platform == "macos-arm64" {
return catalog.runtimes.first { $0.platform == "macos-arm64" }
}
if platform == "macos-x64" {
return catalog.runtimes.first { $0.platform == "macos-x64" }
}
return catalog.runtimes.first
}
public static func currentRuntimePlatform() -> String {
#if arch(arm64)
return "macos-arm64"
#else
return "macos-x64"
#endif
}
#endif
}
/// Region-aware ordering for local ASR model download mirrors.
public enum LocalASRDownloadSourceSorter {
/// `true` when the system region is mainland China (`CN`).
public static func isChinaMainland(region: Locale.Region? = Locale.current.region) -> Bool {
region?.identifier == "CN"
}
/// Lower rank = tried earlier. CN: ModelScope HuggingFace GitHub; elsewhere: HF GitHub ModelScope.
public static func typeRank(_ type: String, chinaFirst: Bool) -> Int {
switch type.lowercased() {
case "modelscope":
return chinaFirst ? 0 : 2
case "huggingface":
return chinaFirst ? 1 : 0
case "github":
return 1
default:
return 3
}
}
public static func sorted(
_ sources: [LocalASRDownloadSource],
region: Locale.Region? = Locale.current.region
) -> [LocalASRDownloadSource] {
let chinaFirst = isChinaMainland(region: region)
return sources.sorted { lhs, rhs in
let leftRank = typeRank(lhs.type, chinaFirst: chinaFirst)
let rightRank = typeRank(rhs.type, chinaFirst: chinaFirst)
if leftRank != rightRank { return leftRank < rightRank }
return lhs.priority < rhs.priority
}
}
}
public enum LocalASRModelCatalogError: Error, LocalizedError {
case missingBundledCatalog
case modelNotFound(String)
public var errorDescription: String? {
switch self {
case .missingBundledCatalog:
return "Missing bundled local ASR catalog."
case .modelNotFound(let id):
return "Local ASR model not found: \(id)"
}
}
}
private final class LocalASRCatalogBundleToken {}
@@ -0,0 +1,45 @@
// MicVoiceAvailability+Keyboard.swift
// OSGKeyboard · Shared
//
// Derives keyboard mic availability from pipeline phase and host readiness.
import Foundation
public enum MicVoiceAvailabilityResolver {
public static func resolve(
phase: KeyboardState.Phase,
micDisabled: Bool,
hasFullAccess: Bool,
appGroupAvailable: Bool,
hostReady: Bool,
isPreparingSession: Bool
) -> MicVoiceAvailability {
switch phase {
case .recording:
return .recording
case .processing, .requestingPermissions:
return .processing
case .error, .denied:
return .unavailable(.hostNotReady)
case .idle:
break
}
if !appGroupAvailable {
return .unavailable(.appGroupUnavailable)
}
if !hasFullAccess {
return .unavailable(.noFullAccess)
}
if micDisabled {
return .unavailable(.missingAPIKey)
}
if isPreparingSession {
return .unavailable(.preparingSession)
}
if hostReady {
return .ready
}
return .unavailable(.hostNotReady)
}
}
@@ -0,0 +1,37 @@
// MicVoiceAvailability.swift
// OSGKeyboard · Shared
//
// Single source of truth for keyboard mic color, hint text, and tap behavior.
import Foundation
/// Whether the keyboard mic can start a Flow utterance right now.
public enum MicVoiceAvailability: Equatable, Sendable {
/// Green tap records immediately without opening the host app.
case ready
/// Orange voice input blocked; see `Reason` for hint copy.
case unavailable(Reason)
/// Red user is actively recording.
case recording
/// White waiting for ASR / cloud polish after stop.
case processing
public enum Reason: Equatable, Sendable {
case missingAPIKey
case hostNotReady
case noFullAccess
case appGroupUnavailable
/// User tapped mic; host app jump in progress, awaiting ready contract.
case preparingSession
}
public var isReady: Bool {
if case .ready = self { return true }
return false
}
public var isUnavailable: Bool {
if case .unavailable = self { return true }
return false
}
}
@@ -111,4 +111,29 @@ extension PersonalDictionary {
if hasNonASCII { return "zh" }
return "en"
}
/// Alias canonical term pairs for deterministic post-ASR correction.
/// Sorted longest-alias-first by the caller (`LocalASRTranscriptCorrector`).
public func localCorrectionPairs() -> [LocalASRCorrectionPair] {
var seen = Set<String>()
var pairs: [LocalASRCorrectionPair] = []
for entry in effectiveEntries {
let term = entry.term.trimmingCharacters(in: .whitespacesAndNewlines)
guard !term.isEmpty else { continue }
for alias in entry.aliases {
let trimmed = alias.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { continue }
guard trimmed.caseInsensitiveCompare(term) != .orderedSame else { continue }
let key = "\(trimmed.lowercased())|\(term.lowercased())"
guard seen.insert(key).inserted else { continue }
pairs.append(LocalASRCorrectionPair(alias: trimmed, term: term))
}
}
return pairs.sorted { lhs, rhs in
if lhs.alias.count != rhs.alias.count {
return lhs.alias.count > rhs.alias.count
}
return lhs.alias.localizedCaseInsensitiveCompare(rhs.alias) == .orderedAscending
}
}
}
@@ -7,17 +7,36 @@
import Foundation
extension PersonalDictionary {
public static let kvsKeyV2 = "personalDictionary.v2"
public static let legacyKVSKey = "personalDictionary.v1"
public static let tombstoneRetention: TimeInterval = 90 * 24 * 60 * 60
/// Merges two dictionary snapshots for cross-device sync.
///
/// Rules:
/// - Apply `clearedAt` and deletion tombstones before entry union.
/// - Same `id`: keep the entry with the newer `updatedAt`.
/// - Same canonical term (case-insensitive) but different `id`: union
/// aliases, take max `usageCount`, keep the newer entry's fields.
public static func merge(local: PersonalDictionary, remote: PersonalDictionary) -> PersonalDictionary {
let clearedAt = later(of: local.clearedAt, and: remote.clearedAt)
var deletedIDs = local.deletedEntryIDs
for (id, date) in remote.deletedEntryIDs {
if let existing = deletedIDs[id] {
deletedIDs[id] = max(existing, date)
} else {
deletedIDs[id] = date
}
}
deletedIDs = pruneTombstones(deletedIDs, clearedAt: clearedAt)
var mergedByID: [UUID: Entry] = [:]
var canonicalOwner: [String: UUID] = [:]
func insertOrMerge(_ candidate: Entry) {
if deletedIDs[candidate.id] != nil { return }
if let clearedAt, candidate.createdAt <= clearedAt { return }
let key = candidate.term.lowercased()
if let existingID = canonicalOwner[key], var existing = mergedByID[existingID] {
if candidate.id == existingID {
@@ -56,10 +75,51 @@ extension PersonalDictionary {
return PersonalDictionary(
entries: mergedEntries,
version: max(local.version, remote.version) + 1,
lastSyncedAt: lastSyncedAt
lastSyncedAt: lastSyncedAt,
deletedEntryIDs: deletedIDs,
clearedAt: clearedAt
)
}
public mutating func recordDeletion(of entryID: UUID, at date: Date = Date()) {
deletedEntryIDs[entryID] = date
entries.removeAll { $0.id == entryID }
}
public mutating func recordClearAll(at date: Date = Date()) {
entries.removeAll()
clearedAt = date
}
public mutating func pruneTombstonesIfNeeded() {
deletedEntryIDs = Self.pruneTombstones(deletedEntryIDs, clearedAt: clearedAt)
}
private static func pruneTombstones(
_ tombstones: [UUID: Date],
clearedAt: Date?
) -> [UUID: Date] {
let cutoff = Date().addingTimeInterval(-tombstoneRetention)
return tombstones.filter { _, deletedAt in
if deletedAt < cutoff { return false }
if let clearedAt, deletedAt <= clearedAt { return false }
return true
}
}
private static func later(of lhs: Date?, and rhs: Date?) -> Date? {
switch (lhs, rhs) {
case let (left?, right?):
return max(left, right)
case (nil, let right?):
return right
case (let left?, nil):
return left
case (nil, nil):
return nil
}
}
private static func resolveEntryConflict(existing: Entry, incoming: Entry) -> Entry {
incoming.updatedAt >= existing.updatedAt ? incoming : existing
}
@@ -23,17 +23,31 @@ public struct PersonalDictionary: Codable, Sendable, Equatable {
public var version: Int
/// When this dictionary blob was last successfully pushed to iCloud KVS.
public var lastSyncedAt: Date?
/// Tombstones for deleted entries prevents remote resurrections.
public var deletedEntryIDs: [UUID: Date]
/// When set, entries created at or before this instant are excluded from merge.
public var clearedAt: Date?
public init(entries: [Entry] = [], version: Int = 1, lastSyncedAt: Date? = nil) {
public init(
entries: [Entry] = [],
version: Int = 1,
lastSyncedAt: Date? = nil,
deletedEntryIDs: [UUID: Date] = [:],
clearedAt: Date? = nil
) {
self.entries = entries
self.version = version
self.lastSyncedAt = lastSyncedAt
self.deletedEntryIDs = deletedEntryIDs
self.clearedAt = clearedAt
}
private enum CodingKeys: String, CodingKey {
case entries
case version
case lastSyncedAt
case deletedEntryIDs
case clearedAt
}
public init(from decoder: Decoder) throws {
@@ -41,6 +55,8 @@ public struct PersonalDictionary: Codable, Sendable, Equatable {
entries = try container.decodeIfPresent([Entry].self, forKey: .entries) ?? []
version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1
lastSyncedAt = try container.decodeIfPresent(Date.self, forKey: .lastSyncedAt)
deletedEntryIDs = try container.decodeIfPresent([UUID: Date].self, forKey: .deletedEntryIDs) ?? [:]
clearedAt = try container.decodeIfPresent(Date.self, forKey: .clearedAt)
}
public func encode(to encoder: Encoder) throws {
@@ -48,6 +64,10 @@ public struct PersonalDictionary: Codable, Sendable, Equatable {
try container.encode(entries, forKey: .entries)
try container.encode(version, forKey: .version)
try container.encodeIfPresent(lastSyncedAt, forKey: .lastSyncedAt)
if !deletedEntryIDs.isEmpty {
try container.encode(deletedEntryIDs, forKey: .deletedEntryIDs)
}
try container.encodeIfPresent(clearedAt, forKey: .clearedAt)
}
public struct Entry: Codable, Sendable, Equatable, Identifiable {
@@ -24,6 +24,10 @@ public struct PolishContext: Sendable {
/// bias terminology choices.
public let precedingText: String?
/// Extra dictionary block appended after `PersonalDictionary.promptFragment()`
/// (e.g. builtin `phrases.tsv` terms on macOS local ASR).
public let dictionarySupplement: String?
/// Cap on how many characters of `precedingText` we actually
/// include in the prompt. The full preceding text is often
/// hundreds of KB in a long note we only need the tail.
@@ -33,11 +37,13 @@ public struct PolishContext: Sendable {
appContext: AppContext = .unknown,
intensity: PolishIntensity = .default,
precedingText: String? = nil,
dictionarySupplement: String? = nil,
maxPrecedingChars: Int = 500
) {
self.appContext = appContext
self.intensity = intensity
self.precedingText = precedingText
self.dictionarySupplement = dictionarySupplement
self.maxPrecedingChars = maxPrecedingChars
}
@@ -36,7 +36,11 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
didSet {
guard oldValue != apiKey, !isSyncingProviderAPIKey else { return }
do {
try Keychain.setAPIKey(apiKey, for: providerId)
try Keychain.setAPIKey(
apiKey,
for: providerId,
useICloudSync: configuration.settingsICloudSyncEnabled
)
} catch {
OSGLog.config.warning("Keychain write failed: \(error.localizedDescription, privacy: .public)")
}
@@ -70,7 +74,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
guard !isApplyingConfiguration, engineMode != configuration.engineMode else { return }
configuration.engineMode = engineMode
applyEngineModeSideEffects()
persistConfiguration()
persistConfiguration(postConfigChanged: true)
}
}
@Published public var hasCompletedOnboarding: Bool {
@@ -0,0 +1,24 @@
// ProviderLogo.swift
// OSGKeyboard · Shared
//
// Maps a provider id to its asset-catalog logo name. Shared by the iOS
// app and the macOS menu-bar app so both show identical brand marks.
import Foundation
public enum ProviderLogo {
/// Asset name for the provider's logo, or `nil` when there is no bundled logo.
public static func assetName(for providerId: String) -> String? {
switch providerId {
case "openai": return "openai"
case "deepseek": return "deepseek"
case "qwen": return "qwen"
case "moonshot": return "moonshot"
case "zhipu": return "zhipu"
case "mimo": return "mimo"
case "apple": return "apple"
case "custom": return "custom"
default: return nil
}
}
}
@@ -0,0 +1,32 @@
// SpeechHistoryEntry.swift
// OSGKeyboard · Shared
//
// A single voice transcription in the cross-device history log.
import Foundation
public struct SpeechHistoryEntry: Codable, Identifiable, Equatable, Sendable {
public let id: UUID
public let text: String
public let createdAt: Date
/// iOS Flow engine mode; nil on macOS captures.
public let engineMode: String?
public init(
id: UUID = UUID(),
text: String,
createdAt: Date = Date(),
engineMode: String? = nil
) {
self.id = id
self.text = text
self.createdAt = createdAt
self.engineMode = engineMode
}
/// First-line preview for compact list rows (macOS history sidebar).
public var previewTitle: String {
let firstLine = text.split(separator: "\n").first.map(String.init) ?? text
return firstLine.count > 36 ? String(firstLine.prefix(36)) + "" : firstLine
}
}
@@ -1,9 +1,8 @@
// SyncedAppSettings.swift
// OSGKeyboard · Shared
//
// User-facing app settings mirrored through iCloud KVS. Excludes
// device-local state (onboarding progress, detected app context,
// personal dictionary blob, and API keys in Keychain).
// Legacy v1 settings blob (read-only migration input). New sync uses
// `SyncedAppSettingsV2`. API keys never belong in KVS payloads.
import Foundation
@@ -23,6 +22,8 @@ public struct SyncedAppSettings: Codable, Sendable, Equatable {
public var polishIntensity: PolishIntensity
public var flowSkipAppSwitch: Bool
public var flowInactivityDuration: FlowInactivityDuration
/// Deprecated decoded for backward compatibility only; never applied.
public var providerAPIKeys: [String: String]
public init(
updatedAt: Date = Date(),
@@ -39,7 +40,8 @@ public struct SyncedAppSettings: Codable, Sendable, Equatable {
cursorDragNavigationEnabled: Bool,
polishIntensity: PolishIntensity,
flowSkipAppSwitch: Bool,
flowInactivityDuration: FlowInactivityDuration
flowInactivityDuration: FlowInactivityDuration,
providerAPIKeys: [String: String] = [:]
) {
self.updatedAt = updatedAt
self.providerId = providerId
@@ -56,11 +58,51 @@ public struct SyncedAppSettings: Codable, Sendable, Equatable {
self.polishIntensity = polishIntensity
self.flowSkipAppSwitch = flowSkipAppSwitch
self.flowInactivityDuration = flowInactivityDuration
self.providerAPIKeys = providerAPIKeys
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
providerId = try container.decode(String.self, forKey: .providerId)
baseURL = try container.decode(String.self, forKey: .baseURL)
model = try container.decode(String.self, forKey: .model)
modeId = try container.decode(String.self, forKey: .modeId)
localeId = try container.decode(String.self, forKey: .localeId)
engineMode = try container.decode(String.self, forKey: .engineMode)
hasAcknowledgedCloudSharing = try container.decode(Bool.self, forKey: .hasAcknowledgedCloudSharing)
uiLanguage = try container.decode(AppUILanguage.self, forKey: .uiLanguage)
translationTargetLocaleId = try container.decode(String.self, forKey: .translationTargetLocaleId)
handednessPreference = try container.decode(HandednessPreference.self, forKey: .handednessPreference)
cursorDragNavigationEnabled = try container.decode(Bool.self, forKey: .cursorDragNavigationEnabled)
polishIntensity = try container.decode(PolishIntensity.self, forKey: .polishIntensity)
flowSkipAppSwitch = try container.decode(Bool.self, forKey: .flowSkipAppSwitch)
flowInactivityDuration = try container.decode(FlowInactivityDuration.self, forKey: .flowInactivityDuration)
providerAPIKeys = try container.decodeIfPresent([String: String].self, forKey: .providerAPIKeys) ?? [:]
}
/// Apply legacy scalar fields only never touches Keychain.
func applyingScalars(to configuration: inout AppGroupConfiguration) {
configuration.providerId = providerId
configuration.baseURL = baseURL
configuration.model = model
configuration.modeId = modeId
configuration.localeId = localeId
configuration.engineMode = engineMode
configuration.hasAcknowledgedCloudSharing = hasAcknowledgedCloudSharing
configuration.uiLanguage = uiLanguage
configuration.translationTargetLocaleId = translationTargetLocaleId
configuration.handednessPreference = handednessPreference
configuration.cursorDragNavigationEnabled = cursorDragNavigationEnabled
configuration.polishIntensity = polishIntensity
configuration.flowSkipAppSwitch = flowSkipAppSwitch
configuration.flowInactivityDuration = flowInactivityDuration
}
}
public extension SyncedAppSettings {
/// Build a cloud payload from the current App Group configuration.
static let legacyKVSKey = "appSettings.v1"
static func from(configuration: AppGroupConfiguration, updatedAt: Date = Date()) -> SyncedAppSettings {
SyncedAppSettings(
updatedAt: updatedAt,
@@ -80,28 +122,4 @@ public extension SyncedAppSettings {
flowInactivityDuration: configuration.flowInactivityDuration
)
}
/// Apply syncable fields onto a configuration, preserving device-local
/// fields such as onboarding progress and the personal dictionary.
func applying(to configuration: inout AppGroupConfiguration) {
configuration.providerId = providerId
configuration.baseURL = baseURL
configuration.model = model
configuration.modeId = modeId
configuration.localeId = localeId
configuration.engineMode = engineMode
configuration.hasAcknowledgedCloudSharing = hasAcknowledgedCloudSharing
configuration.uiLanguage = uiLanguage
configuration.translationTargetLocaleId = translationTargetLocaleId
configuration.handednessPreference = handednessPreference
configuration.cursorDragNavigationEnabled = cursorDragNavigationEnabled
configuration.polishIntensity = polishIntensity
configuration.flowSkipAppSwitch = flowSkipAppSwitch
configuration.flowInactivityDuration = flowInactivityDuration
}
/// Last-write-wins merge for whole settings blobs.
static func merge(local: SyncedAppSettings, remote: SyncedAppSettings) -> SyncedAppSettings {
remote.updatedAt >= local.updatedAt ? remote : local
}
}
@@ -0,0 +1,237 @@
// SyncedAppSettingsV2.swift
// OSGKeyboard · Shared
//
// Versioned settings payload with per-field merge metadata. API keys are
// intentionally excluded they sync through iCloud Keychain.
import Foundation
public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
public static let schemaVersion = 2
public static let kvsKey = "appSettings.v2"
public var schemaVersion: Int
public var providerId: SyncedField<String>
public var baseURL: SyncedField<String>
public var model: SyncedField<String>
public var modeId: SyncedField<String>
public var localeId: SyncedField<String>
public var engineMode: SyncedField<String>
public var hasAcknowledgedCloudSharing: SyncedField<Bool>
public var uiLanguage: SyncedField<AppUILanguage>
public var translationTargetLocaleId: SyncedField<String>
public var handednessPreference: SyncedField<HandednessPreference>
public var cursorDragNavigationEnabled: SyncedField<Bool>
public var polishIntensity: SyncedField<PolishIntensity>
public var flowSkipAppSwitch: SyncedField<Bool>
public var flowInactivityDuration: SyncedField<FlowInactivityDuration>
public init(
schemaVersion: Int = Self.schemaVersion,
providerId: SyncedField<String>,
baseURL: SyncedField<String>,
model: SyncedField<String>,
modeId: SyncedField<String>,
localeId: SyncedField<String>,
engineMode: SyncedField<String>,
hasAcknowledgedCloudSharing: SyncedField<Bool>,
uiLanguage: SyncedField<AppUILanguage>,
translationTargetLocaleId: SyncedField<String>,
handednessPreference: SyncedField<HandednessPreference>,
cursorDragNavigationEnabled: SyncedField<Bool>,
polishIntensity: SyncedField<PolishIntensity>,
flowSkipAppSwitch: SyncedField<Bool>,
flowInactivityDuration: SyncedField<FlowInactivityDuration>
) {
self.schemaVersion = schemaVersion
self.providerId = providerId
self.baseURL = baseURL
self.model = model
self.modeId = modeId
self.localeId = localeId
self.engineMode = engineMode
self.hasAcknowledgedCloudSharing = hasAcknowledgedCloudSharing
self.uiLanguage = uiLanguage
self.translationTargetLocaleId = translationTargetLocaleId
self.handednessPreference = handednessPreference
self.cursorDragNavigationEnabled = cursorDragNavigationEnabled
self.polishIntensity = polishIntensity
self.flowSkipAppSwitch = flowSkipAppSwitch
self.flowInactivityDuration = flowInactivityDuration
}
/// Monotonic stamp used for `settingsCloudUpdatedAt` bookkeeping.
public var latestUpdatedAt: Date {
[
providerId.updatedAt,
baseURL.updatedAt,
model.updatedAt,
modeId.updatedAt,
localeId.updatedAt,
engineMode.updatedAt,
hasAcknowledgedCloudSharing.updatedAt,
uiLanguage.updatedAt,
translationTargetLocaleId.updatedAt,
handednessPreference.updatedAt,
cursorDragNavigationEnabled.updatedAt,
polishIntensity.updatedAt,
flowSkipAppSwitch.updatedAt,
flowInactivityDuration.updatedAt,
].max() ?? .distantPast
}
}
public extension SyncedAppSettingsV2 {
static func from(configuration: AppGroupConfiguration, deviceID: String) -> SyncedAppSettingsV2 {
seeded(from: configuration, deviceID: deviceID, updatedAt: Date())
}
/// Build a payload from configuration using one shared timestamp (for merge bookkeeping).
static func seeded(
from configuration: AppGroupConfiguration,
deviceID: String,
updatedAt: Date
) -> SyncedAppSettingsV2 {
func field<T>(_ value: T) -> SyncedField<T> {
SyncedField(value: value, updatedAt: updatedAt, deviceID: deviceID)
}
return SyncedAppSettingsV2(
providerId: field(configuration.providerId),
baseURL: field(configuration.baseURL),
model: field(configuration.model),
modeId: field(configuration.modeId),
localeId: field(configuration.localeId),
engineMode: field(configuration.engineMode),
hasAcknowledgedCloudSharing: field(configuration.hasAcknowledgedCloudSharing),
uiLanguage: field(configuration.uiLanguage),
translationTargetLocaleId: field(configuration.translationTargetLocaleId),
handednessPreference: field(configuration.handednessPreference),
cursorDragNavigationEnabled: field(configuration.cursorDragNavigationEnabled),
polishIntensity: field(configuration.polishIntensity),
flowSkipAppSwitch: field(configuration.flowSkipAppSwitch),
flowInactivityDuration: field(configuration.flowInactivityDuration)
)
}
/// Upgrade a legacy v1 blob into per-field metadata on this device.
static func migrated(from legacy: SyncedAppSettings, deviceID: String) -> SyncedAppSettingsV2 {
let stamp = legacy.updatedAt
func field<T>(_ value: T) -> SyncedField<T> {
SyncedField(value: value, updatedAt: stamp, deviceID: deviceID)
}
return SyncedAppSettingsV2(
providerId: field(legacy.providerId),
baseURL: field(legacy.baseURL),
model: field(legacy.model),
modeId: field(legacy.modeId),
localeId: field(legacy.localeId),
engineMode: field(legacy.engineMode),
hasAcknowledgedCloudSharing: field(legacy.hasAcknowledgedCloudSharing),
uiLanguage: field(legacy.uiLanguage),
translationTargetLocaleId: field(legacy.translationTargetLocaleId),
handednessPreference: field(legacy.handednessPreference),
cursorDragNavigationEnabled: field(legacy.cursorDragNavigationEnabled),
polishIntensity: field(legacy.polishIntensity),
flowSkipAppSwitch: field(legacy.flowSkipAppSwitch),
flowInactivityDuration: field(legacy.flowInactivityDuration)
)
}
static func merge(local: SyncedAppSettingsV2, remote: SyncedAppSettingsV2) -> SyncedAppSettingsV2 {
SyncedAppSettingsV2(
providerId: .merge(local: local.providerId, remote: remote.providerId),
baseURL: .merge(local: local.baseURL, remote: remote.baseURL),
model: .merge(local: local.model, remote: remote.model),
modeId: .merge(local: local.modeId, remote: remote.modeId),
localeId: .merge(local: local.localeId, remote: remote.localeId),
engineMode: .merge(local: local.engineMode, remote: remote.engineMode),
hasAcknowledgedCloudSharing: .merge(
local: local.hasAcknowledgedCloudSharing,
remote: remote.hasAcknowledgedCloudSharing
),
uiLanguage: .merge(local: local.uiLanguage, remote: remote.uiLanguage),
translationTargetLocaleId: .merge(
local: local.translationTargetLocaleId,
remote: remote.translationTargetLocaleId
),
handednessPreference: .merge(local: local.handednessPreference, remote: remote.handednessPreference),
cursorDragNavigationEnabled: .merge(
local: local.cursorDragNavigationEnabled,
remote: remote.cursorDragNavigationEnabled
),
polishIntensity: .merge(local: local.polishIntensity, remote: remote.polishIntensity),
flowSkipAppSwitch: .merge(local: local.flowSkipAppSwitch, remote: remote.flowSkipAppSwitch),
flowInactivityDuration: .merge(
local: local.flowInactivityDuration,
remote: remote.flowInactivityDuration
)
)
}
func applying(to configuration: inout AppGroupConfiguration) {
configuration.providerId = providerId.value
configuration.baseURL = baseURL.value
configuration.model = model.value
configuration.modeId = modeId.value
configuration.localeId = localeId.value
configuration.engineMode = engineMode.value
configuration.hasAcknowledgedCloudSharing = hasAcknowledgedCloudSharing.value
configuration.uiLanguage = uiLanguage.value
configuration.translationTargetLocaleId = translationTargetLocaleId.value
configuration.handednessPreference = handednessPreference.value
configuration.cursorDragNavigationEnabled = cursorDragNavigationEnabled.value
configuration.polishIntensity = polishIntensity.value
configuration.flowSkipAppSwitch = flowSkipAppSwitch.value
configuration.flowInactivityDuration = flowInactivityDuration.value
}
/// Stamp fields whose values differ from `configuration` with this device id.
func patchLocalChanges(from configuration: AppGroupConfiguration, deviceID: String) -> SyncedAppSettingsV2 {
var copy = self
func patch<T: Equatable>(_ field: inout SyncedField<T>, value: T) {
guard field.value != value else { return }
field = .make(value: value, deviceID: deviceID)
}
patch(&copy.providerId, value: configuration.providerId)
patch(&copy.baseURL, value: configuration.baseURL)
patch(&copy.model, value: configuration.model)
patch(&copy.modeId, value: configuration.modeId)
patch(&copy.localeId, value: configuration.localeId)
patch(&copy.engineMode, value: configuration.engineMode)
patch(&copy.hasAcknowledgedCloudSharing, value: configuration.hasAcknowledgedCloudSharing)
patch(&copy.uiLanguage, value: configuration.uiLanguage)
patch(&copy.translationTargetLocaleId, value: configuration.translationTargetLocaleId)
patch(&copy.handednessPreference, value: configuration.handednessPreference)
patch(&copy.cursorDragNavigationEnabled, value: configuration.cursorDragNavigationEnabled)
patch(&copy.polishIntensity, value: configuration.polishIntensity)
patch(&copy.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
patch(&copy.flowInactivityDuration, value: configuration.flowInactivityDuration)
return copy
}
/// Refresh only fields owned by `deviceID` from the current local configuration.
func refreshedLocalFields(from configuration: AppGroupConfiguration, deviceID: String) -> SyncedAppSettingsV2 {
var copy = self
let now = Date()
func touch<T>(_ field: inout SyncedField<T>, value: T) {
guard field.deviceID == deviceID else { return }
field.value = value
field.updatedAt = now
}
touch(&copy.providerId, value: configuration.providerId)
touch(&copy.baseURL, value: configuration.baseURL)
touch(&copy.model, value: configuration.model)
touch(&copy.modeId, value: configuration.modeId)
touch(&copy.localeId, value: configuration.localeId)
touch(&copy.engineMode, value: configuration.engineMode)
touch(&copy.hasAcknowledgedCloudSharing, value: configuration.hasAcknowledgedCloudSharing)
touch(&copy.uiLanguage, value: configuration.uiLanguage)
touch(&copy.translationTargetLocaleId, value: configuration.translationTargetLocaleId)
touch(&copy.handednessPreference, value: configuration.handednessPreference)
touch(&copy.cursorDragNavigationEnabled, value: configuration.cursorDragNavigationEnabled)
touch(&copy.polishIntensity, value: configuration.polishIntensity)
touch(&copy.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
touch(&copy.flowInactivityDuration, value: configuration.flowInactivityDuration)
return copy
}
}
@@ -0,0 +1,29 @@
// SyncedField.swift
// OSGKeyboard · Shared
//
// Per-field metadata for conflict-free settings merge across devices.
import Foundation
public struct SyncedField<T: Codable & Equatable & Sendable>: Codable, Equatable, Sendable {
public var value: T
public var updatedAt: Date
public var deviceID: String
public init(value: T, updatedAt: Date = Date(), deviceID: String) {
self.value = value
self.updatedAt = updatedAt
self.deviceID = deviceID
}
/// Pick the field with the newer `updatedAt`; ties break lexicographically on `deviceID`.
public static func merge(local: SyncedField<T>, remote: SyncedField<T>) -> SyncedField<T> {
if remote.updatedAt > local.updatedAt { return remote }
if local.updatedAt > remote.updatedAt { return local }
return remote.deviceID >= local.deviceID ? remote : local
}
public static func make(value: T, deviceID: String) -> SyncedField<T> {
SyncedField(value: value, updatedAt: Date(), deviceID: deviceID)
}
}
@@ -0,0 +1,140 @@
// SyncedSpeechHistory.swift
// OSGKeyboard · Shared
//
// iCloud KVS payload for speech history. Tombstones and `clearedAt`
// propagate single-entry deletes and "clear all" across devices.
import Foundation
public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
public static let schemaVersion = 2
public static let kvsKey = "speechHistory.v2"
public static let legacyKVSKey = "speechHistory.v1"
public static let maxEntries = 300
/// Tombstones older than this window may be pruned during merge.
public static let tombstoneRetention: TimeInterval = 90 * 24 * 60 * 60
public var schemaVersion: Int
public var updatedAt: Date
public var entries: [SpeechHistoryEntry]
/// Entry IDs deleted on any device, with deletion timestamps.
public var deletedEntryIDs: [UUID: Date]
/// When set, entries created at or before this instant are excluded.
public var clearedAt: Date?
public init(
schemaVersion: Int = Self.schemaVersion,
updatedAt: Date = Date(),
entries: [SpeechHistoryEntry] = [],
deletedEntryIDs: [UUID: Date] = [:],
clearedAt: Date? = nil
) {
self.schemaVersion = schemaVersion
self.updatedAt = updatedAt
self.entries = entries
self.deletedEntryIDs = deletedEntryIDs
self.clearedAt = clearedAt
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
entries = try container.decodeIfPresent([SpeechHistoryEntry].self, forKey: .entries) ?? []
if let map = try container.decodeIfPresent([UUID: Date].self, forKey: .deletedEntryIDs) {
deletedEntryIDs = map
} else if let legacyIDs = try container.decodeIfPresent([UUID].self, forKey: .deletedEntryIDs) {
let stamp = Date()
deletedEntryIDs = Dictionary(uniqueKeysWithValues: legacyIDs.map { ($0, stamp) })
} else {
deletedEntryIDs = [:]
}
clearedAt = try container.decodeIfPresent(Date.self, forKey: .clearedAt)
}
public static let empty = SyncedSpeechHistory(updatedAt: .distantPast)
/// Union entries by id (newer `createdAt` wins), apply tombstones and clear.
public static func merge(local: SyncedSpeechHistory, remote: SyncedSpeechHistory) -> SyncedSpeechHistory {
let clearedAt = later(of: local.clearedAt, and: remote.clearedAt)
var deletedIDs = local.deletedEntryIDs
for (id, date) in remote.deletedEntryIDs {
if let existing = deletedIDs[id] {
deletedIDs[id] = max(existing, date)
} else {
deletedIDs[id] = date
}
}
deletedIDs = pruneTombstones(deletedIDs, clearedAt: clearedAt)
var byID: [UUID: SpeechHistoryEntry] = [:]
for entry in local.entries + remote.entries {
if deletedIDs[entry.id] != nil { continue }
if let clearedAt, entry.createdAt <= clearedAt { continue }
if let existing = byID[entry.id] {
byID[entry.id] = entry.createdAt >= existing.createdAt ? entry : existing
} else {
byID[entry.id] = entry
}
}
var entries = Array(byID.values).sorted { $0.createdAt > $1.createdAt }
if entries.count > maxEntries {
entries = Array(entries.prefix(maxEntries))
}
return SyncedSpeechHistory(
updatedAt: max(local.updatedAt, remote.updatedAt),
entries: entries,
deletedEntryIDs: deletedIDs,
clearedAt: clearedAt
)
}
/// Trim to the newest `maxEntries` rows (call after local-only appends).
public mutating func trimEntries() {
guard entries.count > Self.maxEntries else { return }
entries = Array(entries.sorted { $0.createdAt > $1.createdAt }.prefix(Self.maxEntries))
updatedAt = Date()
}
public mutating func pruneTombstonesIfNeeded() {
deletedEntryIDs = Self.pruneTombstones(deletedEntryIDs, clearedAt: clearedAt)
}
private static func pruneTombstones(
_ tombstones: [UUID: Date],
clearedAt: Date?
) -> [UUID: Date] {
let cutoff = Date().addingTimeInterval(-tombstoneRetention)
return tombstones.filter { _, deletedAt in
if deletedAt < cutoff {
return false
}
if let clearedAt, deletedAt <= clearedAt {
return false
}
return true
}
}
private static func later(of lhs: Date?, and rhs: Date?) -> Date? {
switch (lhs, rhs) {
case let (left?, right?):
return max(left, right)
case (nil, let right?):
return right
case (let left?, nil):
return left
case (nil, nil):
return nil
}
}
}
extension SyncedSpeechHistory {
mutating func recordClearAll(at date: Date = Date()) {
entries.removeAll()
clearedAt = date
}
}
@@ -0,0 +1,148 @@
// SyncedUsageStatisticsV2.swift
// OSGKeyboard · Shared
//
// Per-device grow-only counters (G-Counter) for cumulative usage stats.
import Foundation
public struct UsageStatisticsDeviceSlice: Codable, Equatable, Sendable {
public var updatedAt: Date
public var dictationDurationSeconds: TimeInterval
public var dictationCharacterCount: Int
public var translationCharacterCount: Int
public init(
updatedAt: Date = Date(),
dictationDurationSeconds: TimeInterval = 0,
dictationCharacterCount: Int = 0,
translationCharacterCount: Int = 0
) {
self.updatedAt = updatedAt
self.dictationDurationSeconds = dictationDurationSeconds
self.dictationCharacterCount = dictationCharacterCount
self.translationCharacterCount = translationCharacterCount
}
public static func merge(local: UsageStatisticsDeviceSlice, remote: UsageStatisticsDeviceSlice) -> UsageStatisticsDeviceSlice {
UsageStatisticsDeviceSlice(
updatedAt: max(local.updatedAt, remote.updatedAt),
dictationDurationSeconds: max(local.dictationDurationSeconds, remote.dictationDurationSeconds),
dictationCharacterCount: max(local.dictationCharacterCount, remote.dictationCharacterCount),
translationCharacterCount: max(local.translationCharacterCount, remote.translationCharacterCount)
)
}
public var totals: UsageStatistics {
UsageStatistics(
updatedAt: updatedAt,
dictationDurationSeconds: dictationDurationSeconds,
dictationCharacterCount: dictationCharacterCount,
translationCharacterCount: translationCharacterCount
)
}
}
public struct SyncedUsageStatisticsV2: Codable, Equatable, Sendable {
public static let schemaVersion = 2
public static let kvsKey = "usageStatistics.v2"
public var schemaVersion: Int
public var devices: [String: UsageStatisticsDeviceSlice]
public init(schemaVersion: Int = Self.schemaVersion, devices: [String: UsageStatisticsDeviceSlice] = [:]) {
self.schemaVersion = schemaVersion
self.devices = devices
}
public static let empty = SyncedUsageStatisticsV2()
public var aggregated: UsageStatistics {
var duration: TimeInterval = 0
var dictation = 0
var translation = 0
var latest = Date.distantPast
for slice in devices.values {
duration += slice.dictationDurationSeconds
dictation += slice.dictationCharacterCount
translation += slice.translationCharacterCount
latest = max(latest, slice.updatedAt)
}
return UsageStatistics(
updatedAt: latest,
dictationDurationSeconds: duration,
dictationCharacterCount: dictation,
translationCharacterCount: translation
)
}
public static func merge(local: SyncedUsageStatisticsV2, remote: SyncedUsageStatisticsV2) -> SyncedUsageStatisticsV2 {
var mergedDevices = local.devices
for (deviceID, remoteSlice) in remote.devices {
if let localSlice = mergedDevices[deviceID] {
mergedDevices[deviceID] = .merge(local: localSlice, remote: remoteSlice)
} else {
mergedDevices[deviceID] = remoteSlice
}
}
return SyncedUsageStatisticsV2(devices: mergedDevices)
}
public static func migrated(from legacy: UsageStatistics, deviceID: String) -> SyncedUsageStatisticsV2 {
guard legacy != .zero else { return .empty }
return SyncedUsageStatisticsV2(devices: [
deviceID: UsageStatisticsDeviceSlice(
updatedAt: legacy.updatedAt,
dictationDurationSeconds: legacy.dictationDurationSeconds,
dictationCharacterCount: legacy.dictationCharacterCount,
translationCharacterCount: legacy.translationCharacterCount
),
])
}
}
public enum SyncedUsageStatisticsStorage {
public static let storageKey = SyncedUsageStatisticsV2.kvsKey
public static let legacyStorageKey = "usageStatistics.v1"
public static func load(from defaults: UserDefaults) -> SyncedUsageStatisticsV2 {
if let data = defaults.data(forKey: storageKey),
let payload = try? JSONDecoder().decode(SyncedUsageStatisticsV2.self, from: data) {
return payload
}
return migrateLegacyIfNeeded(into: defaults)
}
public static func save(_ payload: SyncedUsageStatisticsV2, to defaults: UserDefaults) {
guard let data = try? JSONEncoder().encode(payload) else { return }
defaults.set(data, forKey: storageKey)
}
public static func migrateLegacyIfNeeded(into defaults: UserDefaults) -> SyncedUsageStatisticsV2 {
let deviceID = SyncDeviceID.current(defaults: defaults)
let legacy = UsageStatisticsStorage.migrateLegacyIfNeeded(into: defaults)
let migrated = SyncedUsageStatisticsV2.migrated(from: legacy, deviceID: deviceID)
if migrated != .empty {
save(migrated, to: defaults)
}
return migrated
}
public static func currentDeviceSlice(
from defaults: UserDefaults,
deviceID: String? = nil
) -> UsageStatisticsDeviceSlice {
let id = deviceID ?? SyncDeviceID.current(defaults: defaults)
return load(from: defaults).devices[id] ?? UsageStatisticsDeviceSlice()
}
public static func upsertCurrentDeviceSlice(
_ slice: UsageStatisticsDeviceSlice,
defaults: UserDefaults,
deviceID: String? = nil
) {
let id = deviceID ?? SyncDeviceID.current(defaults: defaults)
var payload = load(from: defaults)
payload.devices[id] = slice
save(payload, to: defaults)
}
}
@@ -0,0 +1,86 @@
// UsageStatistics.swift
// OSGKeyboard · Shared
//
// Cumulative dictation metrics shown on the home / dashboard stats cards.
// Mirrored through iCloud KVS when settings sync is enabled.
import Foundation
public struct UsageStatistics: Codable, Equatable, Sendable {
public var updatedAt: Date
public var dictationDurationSeconds: TimeInterval
public var dictationCharacterCount: Int
public var translationCharacterCount: Int
public init(
updatedAt: Date = Date(),
dictationDurationSeconds: TimeInterval = 0,
dictationCharacterCount: Int = 0,
translationCharacterCount: Int = 0
) {
self.updatedAt = updatedAt
self.dictationDurationSeconds = dictationDurationSeconds
self.dictationCharacterCount = dictationCharacterCount
self.translationCharacterCount = translationCharacterCount
}
public static let zero = UsageStatistics(updatedAt: .distantPast)
/// Combine lifetime totals from two devices. After merge, each device
/// continues accumulating locally so `max` converges to the union.
public static func merge(local: UsageStatistics, remote: UsageStatistics) -> UsageStatistics {
UsageStatistics(
updatedAt: max(local.updatedAt, remote.updatedAt),
dictationDurationSeconds: max(local.dictationDurationSeconds, remote.dictationDurationSeconds),
dictationCharacterCount: max(local.dictationCharacterCount, remote.dictationCharacterCount),
translationCharacterCount: max(local.translationCharacterCount, remote.translationCharacterCount)
)
}
}
public enum UsageStatisticsStorage {
public static let storageKey = "usageStatistics.v1"
/// Legacy macOS dashboard counter (word split); migrated on first load.
public static let legacyMacTotalWordsKey = "mac.totalWords"
/// PreApp Group iOS storage in `UserDefaults.standard`.
public static let legacyStandardDefaultsKey = "usageStatistics.v1"
public static func load(from defaults: UserDefaults) -> UsageStatistics {
if let data = defaults.data(forKey: storageKey),
let stats = try? JSONDecoder().decode(UsageStatistics.self, from: data) {
return stats
}
return .zero
}
public static func save(_ stats: UsageStatistics, to defaults: UserDefaults) {
guard let data = try? JSONEncoder().encode(stats) else { return }
defaults.set(data, forKey: storageKey)
}
/// One-time imports from older per-platform keys.
public static func migrateLegacyIfNeeded(into defaults: UserDefaults) -> UsageStatistics {
var stats = load(from: defaults)
guard stats == .zero else { return stats }
let legacyWords = defaults.integer(forKey: legacyMacTotalWordsKey)
if legacyWords > 0 {
stats.dictationCharacterCount = legacyWords
stats.updatedAt = Date()
save(stats, to: defaults)
return stats
}
#if os(iOS)
if let data = UserDefaults.standard.data(forKey: legacyStandardDefaultsKey),
let legacy = try? JSONDecoder().decode(UsageStatistics.self, from: data),
legacy != .zero {
stats = legacy
stats.updatedAt = Date()
save(stats, to: defaults)
}
#endif
return stats
}
}
@@ -0,0 +1,165 @@
{
"schemaVersion": 1,
"defaultModelId": "sherpa-qwen3-0.6b-int8",
"runtimes": [
{
"id": "sherpa-onnx-1.13.4-macos-arm64",
"displayName": "sherpa-onnx 1.13.4 (Apple Silicon)",
"installRelativePath": "runtimes/sherpa-onnx-1.13.4-macos-arm64",
"binaryCandidates": ["bin/sherpa-onnx-offline", "sherpa-onnx-offline"],
"archiveFileName": "sherpa-onnx-v1.13.4-osx-arm64-static-no-tts.tar.bz2",
"sizeBytes": 120000000,
"platform": "macos-arm64",
"sources": [
{
"type": "github",
"priority": 1,
"url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.13.4/sherpa-onnx-v1.13.4-osx-arm64-static-no-tts.tar.bz2"
}
]
},
{
"id": "sherpa-onnx-1.13.4-macos-x64",
"displayName": "sherpa-onnx 1.13.4 (Intel)",
"installRelativePath": "runtimes/sherpa-onnx-1.13.4-macos-x64",
"binaryCandidates": ["bin/sherpa-onnx-offline", "sherpa-onnx-offline"],
"archiveFileName": "sherpa-onnx-v1.13.4-osx-x64-static-no-tts.tar.bz2",
"sizeBytes": 130000000,
"platform": "macos-x64",
"sources": [
{
"type": "github",
"priority": 1,
"url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.13.4/sherpa-onnx-v1.13.4-osx-x64-static-no-tts.tar.bz2"
}
]
}
],
"models": [
{
"id": "sherpa-qwen3-0.6b-int8",
"displayName": "Qwen3-ASR 0.6B",
"backend": "sherpaQwen3",
"runtimePlatform": "macos",
"sizeBytes": 650000000,
"recommendedLocales": ["zh-CN", "en-US"],
"supportsHotwords": true,
"hotwordMode": "recognizerScoped",
"installKind": "archive",
"installRelativePath": "models/sherpa-qwen3-0.6b-int8",
"archiveBaseName": "sherpa-onnx-qwen3-asr-0.6B-int8-2026-03-25",
"layout": {
"convFrontend": "conv_frontend.onnx",
"encoder": "encoder.int8.onnx",
"decoder": "decoder.int8.onnx",
"tokenizer": "tokenizer"
},
"sources": [
{
"type": "github",
"priority": 1,
"url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-qwen3-asr-0.6B-int8-2026-03-25.tar.bz2"
}
]
},
{
"id": "sherpa-qwen3-1.7b-int8",
"displayName": "Qwen3-ASR 1.7B",
"backend": "sherpaQwen3",
"runtimePlatform": "macos",
"sizeBytes": 1900000000,
"recommendedLocales": ["zh-CN", "en-US"],
"supportsHotwords": true,
"hotwordMode": "recognizerScoped",
"installKind": "repository",
"installRelativePath": "models/sherpa-qwen3-1.7b-int8",
"archiveBaseName": "sherpa-onnx-qwen3-asr-1.7B-int8",
"layout": {
"convFrontend": "conv_frontend.onnx",
"encoder": "encoder.int8.onnx",
"decoder": "decoder.int8.onnx",
"tokenizer": "tokenizer"
},
"sources": [
{
"type": "modelscope",
"priority": 1,
"url": "",
"baseURL": "https://www.modelscope.cn/models/zengshuishui/Qwen3-ASR-onnx/resolve/master/{path}",
"files": [
{ "remotePath": "model_1.7B/conv_frontend.onnx", "localPath": "conv_frontend.onnx", "sizeBytes": 12000000 },
{ "remotePath": "model_1.7B/encoder.int8.onnx", "localPath": "encoder.int8.onnx", "sizeBytes": 900000000 },
{ "remotePath": "model_1.7B/decoder.int8.onnx", "localPath": "decoder.int8.onnx", "sizeBytes": 700000000 },
{ "remotePath": "model_1.7B/tokenizer/merges.txt", "localPath": "tokenizer/merges.txt", "sizeBytes": 500000 },
{ "remotePath": "model_1.7B/tokenizer/vocab.json", "localPath": "tokenizer/vocab.json", "sizeBytes": 3000000 },
{ "remotePath": "model_1.7B/tokenizer/tokenizer.json", "localPath": "tokenizer/tokenizer.json", "sizeBytes": 7000000 },
{ "remotePath": "model_1.7B/tokenizer/tokenizer_config.json", "localPath": "tokenizer/tokenizer_config.json", "sizeBytes": 10000 }
]
},
{
"type": "huggingface",
"priority": 1,
"url": "",
"baseURL": "https://huggingface.co/zengshuishui/Qwen3-ASR-onnx/resolve/main/{path}",
"files": [
{ "remotePath": "model_1.7B/conv_frontend.onnx", "localPath": "conv_frontend.onnx", "sizeBytes": 12000000 },
{ "remotePath": "model_1.7B/encoder.int8.onnx", "localPath": "encoder.int8.onnx", "sizeBytes": 900000000 },
{ "remotePath": "model_1.7B/decoder.int8.onnx", "localPath": "decoder.int8.onnx", "sizeBytes": 700000000 },
{ "remotePath": "model_1.7B/tokenizer/merges.txt", "localPath": "tokenizer/merges.txt", "sizeBytes": 500000 },
{ "remotePath": "model_1.7B/tokenizer/vocab.json", "localPath": "tokenizer/vocab.json", "sizeBytes": 3000000 },
{ "remotePath": "model_1.7B/tokenizer/tokenizer.json", "localPath": "tokenizer/tokenizer.json", "sizeBytes": 7000000 },
{ "remotePath": "model_1.7B/tokenizer/tokenizer_config.json", "localPath": "tokenizer/tokenizer_config.json", "sizeBytes": 10000 }
]
}
]
},
{
"id": "sherpa-paraformer-zh-int8",
"displayName": "Paraformer Large",
"backend": "sherpaParaformer",
"runtimePlatform": "macos",
"sizeBytes": 220000000,
"recommendedLocales": ["zh-CN", "en-US"],
"supportsHotwords": false,
"hotwordMode": "none",
"installKind": "archive",
"installRelativePath": "models/sherpa-paraformer-zh-int8",
"archiveBaseName": "sherpa-onnx-paraformer-zh-int8-2025-10-07",
"layout": {
"paraformerModel": "model.int8.onnx",
"tokens": "tokens.txt"
},
"sources": [
{
"type": "github",
"priority": 1,
"url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-paraformer-zh-int8-2025-10-07.tar.bz2"
}
]
},
{
"id": "sherpa-sensevoice-small-int8",
"displayName": "SenseVoice Small",
"backend": "sherpaSenseVoice",
"runtimePlatform": "macos",
"sizeBytes": 250000000,
"recommendedLocales": ["zh-CN", "en-US", "ja-JP", "ko-KR"],
"supportsHotwords": false,
"hotwordMode": "none",
"installKind": "archive",
"installRelativePath": "models/sherpa-sensevoice-small-int8",
"archiveBaseName": "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17",
"layout": {
"senseVoiceModel": "model.int8.onnx",
"tokens": "tokens.txt"
},
"sources": [
{
"type": "github",
"priority": 1,
"url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17.tar.bz2"
}
]
}
]
}
+1 -1
View File
@@ -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)
}
+27 -8
View File
@@ -16,15 +16,25 @@ public struct AppGroupStore: @unchecked Sendable {
self.defaults = defaults
return
}
guard let available = AppGroup.defaultsIfAvailable else {
#if DEBUG
fatalError("App Group unavailable — inject UserDefaults in tests or fix entitlements.")
#else
// Callers must check `AppGroup.isAvailable` before constructing.
fatalError("App Group unavailable.")
#endif
if let available = AppGroup.defaultsIfAvailable {
self.defaults = available
return
}
self.defaults = available
#if os(iOS)
// iOS app + keyboard extension MUST share the App Group suite; a
// silent `.standard` fallback would desync them. Keep this a hard
// failure so a provisioning mistake is impossible to miss.
#if DEBUG
fatalError("App Group unavailable — inject UserDefaults in tests or fix entitlements.")
#else
fatalError("App Group unavailable.")
#endif
#else
// macOS is a standalone menu-bar app with no keyboard extension to
// stay in sync with, so a missing App Group container is expected;
// fall back to the app's standard defaults.
self.defaults = .standard
#endif
}
private var configuration: AppGroupConfiguration {
@@ -82,6 +92,7 @@ public struct AppGroupStore: @unchecked Sendable {
config.model = openAI.defaultModel
}
}
AppGroupConfigDarwin.postConfigChanged()
}
public func setUILanguage(_ language: AppUILanguage) {
@@ -165,6 +176,14 @@ public struct AppGroupStore: @unchecked Sendable {
AppGroupConfigDarwin.postConfigChanged()
}
public func deletePersonalDictionaryEntry(id: UUID, at date: Date = Date()) {
mutateConfiguration { config in
config.personalDictionary.entries.removeAll { $0.id == id }
config.personalDictionary.deletedEntryIDs[id] = date
}
AppGroupConfigDarwin.postConfigChanged()
}
public var personalDictionaryICloudSyncEnabled: Bool {
get { configuration.personalDictionaryICloudSyncEnabled }
set { setPersonalDictionaryICloudSyncEnabled(newValue) }
@@ -0,0 +1,152 @@
// BuiltinLexiconIndex.swift
// OSGKeyboard · Shared
//
// In-memory index over bundled `phrases.tsv` (~10k computer terms).
// macOS local ASR consumes a Top-N subset; the full index also backs
// polish supplements and future retrieval.
import Foundation
public final class BuiltinLexiconIndex: @unchecked Sendable {
public struct Term: Sendable, Equatable {
public let word: String
public let pinyin: String
public let source: String
public let weight: Int
}
public static let shared = BuiltinLexiconIndex()
private let lock = NSLock()
private var cachedTerms: [Term]?
private let injectedURL: URL?
/// Production singleton loads from the app bundle.
private init() {
injectedURL = nil
}
/// Test / preview hook with an explicit TSV file or inline fixture.
init(fixtureURL: URL) {
injectedURL = fixtureURL
}
/// Parse TSV content without touching the bundle (unit tests).
public static func parseTSV(_ content: String) -> [Term] {
var terms: [Term] = []
terms.reserveCapacity(256)
for (lineIndex, line) in content.split(whereSeparator: \.isNewline).enumerated() {
if lineIndex == 0, line.hasPrefix("word\t") { continue }
let columns = line.split(separator: "\t", omittingEmptySubsequences: false)
guard columns.count >= 4 else { continue }
let word = String(columns[0]).trimmingCharacters(in: .whitespacesAndNewlines)
guard !word.isEmpty else { continue }
let pinyin = String(columns[1])
let source = String(columns[2])
let weight = Int(columns[3]) ?? 1
terms.append(Term(word: word, pinyin: pinyin, source: source, weight: weight))
}
return terms
}
public func termCount() -> Int {
lock.lock()
defer { lock.unlock() }
return loadTermsLocked().count
}
/// Returns canonical words ranked for ASR bias injection.
public func topTerms(
limit: Int,
minimumWeight: Int = 4,
preferredSources: Set<String>? = nil
) -> [String] {
guard limit > 0 else { return [] }
lock.lock()
let all = loadTermsLocked()
lock.unlock()
let filtered = all.filter { term in
guard term.weight >= minimumWeight else { return false }
if let preferredSources, !preferredSources.isEmpty {
return preferredSources.contains(term.source)
}
return true
}
let ranked = filtered.sorted { lhs, rhs in
let leftScore = Self.rankingScore(lhs)
let rightScore = Self.rankingScore(rhs)
if leftScore != rightScore { return leftScore > rightScore }
return lhs.word.localizedCaseInsensitiveCompare(rhs.word) == .orderedAscending
}
var seen = Set<String>()
var words: [String] = []
words.reserveCapacity(min(limit, ranked.count))
for term in ranked {
let key = term.word.lowercased()
guard seen.insert(key).inserted else { continue }
words.append(term.word)
if words.count >= limit { break }
}
return words
}
// MARK: - Private
private func loadTermsLocked() -> [Term] {
if let cachedTerms { return cachedTerms }
let loaded: [Term]
if let injectedURL {
loaded = Self.load(from: injectedURL)
} else if let url = Self.locateBundledPhrasesURL() {
loaded = Self.load(from: url)
} else {
loaded = []
}
cachedTerms = loaded
return loaded
}
private static func load(from url: URL) -> [Term] {
guard let data = try? Data(contentsOf: url),
let content = String(data: data, encoding: .utf8) else {
return []
}
return parseTSV(content)
}
private static func locateBundledPhrasesURL() -> URL? {
let candidates: [Bundle] = [Bundle.main, Bundle(for: BuiltinLexiconIndex.self)]
for bundle in candidates {
if let url = bundle.url(
forResource: "phrases",
withExtension: "tsv",
subdirectory: "CustomLanguageModel/v1"
) {
return url
}
if let url = bundle.url(forResource: "phrases", withExtension: "tsv") {
return url
}
}
return nil
}
private static func rankingScore(_ term: Term) -> Int {
var score = term.weight * 100
if containsLatinLetters(term.word) { score += 50 }
if term.word.count <= 8 { score += 10 }
return score
}
private static func containsLatinLetters(_ text: String) -> Bool {
text.unicodeScalars.contains { scalar in
scalar.isASCII && CharacterSet.letters.contains(scalar)
}
}
}
@@ -16,7 +16,7 @@ public protocol CloudASRTranscribing: Sendable {
}
public enum CloudASRClientFactory {
public static func make(store: AppGroupStore, session: URLSession = .shared) -> CloudASRTranscribing {
public static func make(store: any ConfigurationStore, session: URLSession = .shared) -> CloudASRTranscribing {
let strategy = CloudASRModelCatalog.strategy(for: store.providerId)
switch strategy {
case .zhipuHotwords:
@@ -29,7 +29,7 @@ public enum CloudASRClientFactory {
return AlibabaFunASRClient(
apiKey: store.apiKey,
model: CloudASRModelCatalog.defaultModel(for: store.providerId),
store: store,
persistence: store.cloudASRPersistence,
session: session
)
case .prompt:
@@ -149,12 +149,12 @@ struct ZhipuCloudASRClient: CloudASRTranscribing {
// MARK: - Alibaba Fun-ASR Flash (vocabulary_id + context)
struct AlibabaFunASRClient: CloudASRTranscribing {
/// `UserDefaults` is not `Sendable`; we only touch `persistence` on the
/// actor-isolated cloud ASR path, same as the previous `AppGroupStore` holder.
struct AlibabaFunASRClient: CloudASRTranscribing, @unchecked Sendable {
let apiKey: String
let model: String
// Hold the (@unchecked Sendable) AppGroupStore rather than a raw
// UserDefaults so this struct stays Sendable under strict concurrency.
let store: AppGroupStore
let persistence: UserDefaults
let session: URLSession
func prepare(dictionary: PersonalDictionary) async throws {
@@ -162,7 +162,7 @@ struct AlibabaFunASRClient: CloudASRTranscribing {
dictionary: dictionary,
apiKey: apiKey,
targetModel: CloudASRModelCatalog.alibabaVocabularyTargetModel,
defaults: store.defaults,
defaults: persistence,
session: session
)
}
@@ -179,7 +179,7 @@ struct AlibabaFunASRClient: CloudASRTranscribing {
dictionary: dictionary,
apiKey: apiKey,
targetModel: CloudASRModelCatalog.alibabaVocabularyTargetModel,
defaults: store.defaults,
defaults: persistence,
session: session
)
@@ -8,7 +8,7 @@ import Foundation
import os
public final class CloudASRService: ASRService, @unchecked Sendable {
private let store: AppGroupStore
private let store: any ConfigurationStore
private let session: URLSession
private let localFallback: ASRService
private let lock = OSAllocatedUnfairLock()
@@ -18,7 +18,7 @@ public final class CloudASRService: ASRService, @unchecked Sendable {
private var cancelled = false
public init(
store: AppGroupStore = AppGroupStore(),
store: any ConfigurationStore = AppGroupStore(),
session: URLSession = .shared,
localFallback: ASRService? = nil
) {
@@ -123,6 +123,26 @@ private final class FlowLevelStore: @unchecked Sendable {
}
}
/// Last observed audio tap timestamp. This lets the host publish "ready"
/// only after the microphone pipeline has produced real frames.
private final class FlowAudioProofStore: @unchecked Sendable {
private let lock = OSAllocatedUnfairLock(initialState: TimeInterval(0))
func markFrameReceived() {
lock.withLock { $0 = Date().timeIntervalSince1970 }
}
func reset() {
lock.withLock { $0 = 0 }
}
func hasRecentFrame(maxAge: TimeInterval) -> Bool {
let timestamp = lock.withLock { $0 }
guard timestamp > 0 else { return false }
return Date().timeIntervalSince1970 - timestamp <= maxAge
}
}
/// Route-adaptive downsampling converter, safe to call from the realtime tap.
///
/// `AVAudioEngine.installTap(format:)` traps with an **uncatchable** NSException
@@ -207,6 +227,7 @@ public final class FlowContinuousCapture {
private let streamRelay = FlowCaptureStreamRelay()
private let prerollStore = FlowPrerollStore()
private let levelStore = FlowLevelStore(barCount: FlowCaptureConstants.levelBarCount)
private let audioProofStore = FlowAudioProofStore()
private let gate = OSAllocatedUnfairLock(initialState: UtteranceGatePhase.idle)
private let drainTracker = FlowCaptureDrainTracker()
private let tailSampleCounter = OSAllocatedUnfairLock(initialState: 0)
@@ -229,12 +250,28 @@ public final class FlowContinuousCapture {
public var running: Bool { isRunning }
/// True when the capture session flag, tap, and audio engine are all live.
public var engineIsLive: Bool {
isRunning && didInstallTap && audioEngine.isRunning
}
/// True only when the engine is live and the input tap has recently
/// delivered an actual audio frame.
public func engineHasRecentAudio(maxAge: TimeInterval = 1) -> Bool {
engineIsLive && audioProofStore.hasRecentFrame(maxAge: maxAge)
}
/// Called on the main actor when `engineIsLive` may have changed.
public var onEngineLiveChanged: ((Bool) -> Void)?
/// Configure `.playAndRecord`, install a permanent input tap, start the engine.
public func start() throws {
guard !isRunning else { return }
audioProofStore.reset()
try activateEngine()
isRunning = true
installSessionObservers()
notifyEngineLiveChanged()
}
/// Bring up the audio session + engine for the *current* hardware route.
@@ -289,6 +326,7 @@ public final class FlowContinuousCapture {
let relay = streamRelay
let preroll = prerollStore
let levels = levelStore
let proof = audioProofStore
let tracker = drainTracker
let tailCounter = tailSampleCounter
let policy = drainPolicy
@@ -296,6 +334,7 @@ public final class FlowContinuousCapture {
downsampler: downsampler,
gate: gateLock,
levelStore: levels,
audioProofStore: proof,
prerollStore: preroll,
streamRelay: relay,
drainTracker: tracker,
@@ -332,6 +371,7 @@ public final class FlowContinuousCapture {
audioEngine.stop()
}
isRunning = false
audioProofStore.reset()
downsampler = nil
targetFormat = nil
hwFormat = nil
@@ -339,24 +379,47 @@ public final class FlowContinuousCapture {
false,
options: .notifyOthersOnDeactivation
)
notifyEngineLiveChanged()
}
/// Re-activate capture after returning from background without
/// reinstalling the tap (iOS may deactivate the audio session).
public func reassertIfRunning() {
guard isRunning else { return }
@discardableResult
public func reassertIfRunning() -> Bool {
guard isRunning else { return false }
let session = AVAudioSession.sharedInstance()
try? session.setCategory(
.playAndRecord,
mode: .measurement,
options: [.defaultToSpeaker, .allowBluetoothHFP, .mixWithOthers]
)
try? session.setActive(true, options: .notifyOthersOnDeactivation)
if !audioEngine.isRunning {
try? audioEngine.start()
do {
try session.setCategory(
.playAndRecord,
mode: .measurement,
options: [.defaultToSpeaker, .allowBluetoothHFP, .mixWithOthers]
)
try session.setActive(true, options: .notifyOthersOnDeactivation)
if !audioEngine.isRunning {
try audioEngine.start()
}
notifyEngineLiveChanged()
return engineIsLive
} catch {
notifyEngineLiveChanged()
return false
}
}
public func awaitAudioFlowing(
timeout: TimeInterval,
recentFrameMaxAge: TimeInterval = 1
) async -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if engineHasRecentAudio(maxAge: recentFrameMaxAge) {
return true
}
try? await Task.sleep(nanoseconds: 50_000_000)
}
return engineHasRecentAudio(maxAge: recentFrameMaxAge)
}
// MARK: - Route / interruption recovery
private func installSessionObservers() {
@@ -434,6 +497,7 @@ public final class FlowContinuousCapture {
switch type {
case .began:
log.info("Audio interruption began")
notifyEngineLiveChanged()
case .ended:
guard isRunning else { return }
let shouldResume: Bool
@@ -462,11 +526,17 @@ public final class FlowContinuousCapture {
}
do {
try activateEngine()
notifyEngineLiveChanged()
} catch {
log.error("Engine rebuild failed: \(error.localizedDescription, privacy: .public)")
notifyEngineLiveChanged()
}
}
private func notifyEngineLiveChanged() {
onEngineLiveChanged?(engineIsLive)
}
/// Begin forwarding downsampled buffers to ASR for one utterance.
public func beginUtterance() -> AsyncStream<AudioBufferSnapshot> {
let (stream, continuation) = AsyncStream<AudioBufferSnapshot>.makeStream()
@@ -546,6 +616,7 @@ public final class FlowContinuousCapture {
downsampler: AdaptiveDownsampler,
gate: OSAllocatedUnfairLock<UtteranceGatePhase>,
levelStore: FlowLevelStore,
audioProofStore: FlowAudioProofStore,
prerollStore: FlowPrerollStore,
streamRelay: FlowCaptureStreamRelay,
drainTracker: FlowCaptureDrainTracker,
@@ -553,6 +624,7 @@ public final class FlowContinuousCapture {
drainPolicy: FlowCaptureTailDrainPolicy
) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void {
return { buffer, _ in
audioProofStore.markFrameReceived()
levelStore.update(from: buffer, barCount: FlowCaptureConstants.levelBarCount)
// Derive the converter from the *live* buffer format so a mid-session
@@ -6,6 +6,158 @@
import Foundation
public struct FlowCommand: Codable, Equatable, Sendable {
public enum Action: String, Codable, Sendable {
case startRecording
case stopRecording
case abort
}
public let protocolVersion: Int
public let sessionId: UUID
public let utteranceId: UUID
public let commandSeq: Int64
public let action: Action
public let localeId: String
public let createdAt: TimeInterval
public init(
protocolVersion: Int = 1,
sessionId: UUID,
utteranceId: UUID,
commandSeq: Int64,
action: Action,
localeId: String,
createdAt: TimeInterval = Date().timeIntervalSince1970
) {
self.protocolVersion = protocolVersion
self.sessionId = sessionId
self.utteranceId = utteranceId
self.commandSeq = commandSeq
self.action = action
self.localeId = localeId
self.createdAt = createdAt
}
}
public struct FlowResult: Codable, Equatable, Sendable {
public enum Status: String, Codable, Sendable {
case partial
case final
case error
case aborted
case timeout
}
public let protocolVersion: Int
public let sessionId: UUID
public let utteranceId: UUID
public let commandSeq: Int64
public let status: Status
public let text: String?
public let warning: String?
public let errorKind: FlowSessionKeys.TranscriptionErrorKind?
public let createdAt: TimeInterval
public init(
protocolVersion: Int = 1,
sessionId: UUID,
utteranceId: UUID,
commandSeq: Int64,
status: Status,
text: String? = nil,
warning: String? = nil,
errorKind: FlowSessionKeys.TranscriptionErrorKind? = nil,
createdAt: TimeInterval = Date().timeIntervalSince1970
) {
self.protocolVersion = protocolVersion
self.sessionId = sessionId
self.utteranceId = utteranceId
self.commandSeq = commandSeq
self.status = status
self.text = text
self.warning = warning
self.errorKind = errorKind
self.createdAt = createdAt
}
}
public struct FlowAck: Codable, Equatable, Sendable {
public let protocolVersion: Int
public let sessionId: UUID
public let utteranceId: UUID
public let commandSeq: Int64
public let consumedAt: TimeInterval
public init(
protocolVersion: Int = 1,
sessionId: UUID,
utteranceId: UUID,
commandSeq: Int64,
consumedAt: TimeInterval = Date().timeIntervalSince1970
) {
self.protocolVersion = protocolVersion
self.sessionId = sessionId
self.utteranceId = utteranceId
self.commandSeq = commandSeq
self.consumedAt = consumedAt
}
}
public struct FlowReadySnapshot: Codable, Equatable, Sendable {
public enum Reason: String, Codable, Sendable {
case ready
case noSession
case starting
case audioEngineNotLive
case waitingForAudioProof
case recording
case processing
case permissionMissing
case appGroupUnavailable
case hostLost
case error
}
public let protocolVersion: Int
public let sessionId: UUID?
public let ready: Bool
public let reason: Reason
public let heartbeatAt: TimeInterval
public let readyAt: TimeInterval?
public let audioProofAt: TimeInterval?
public let engineMode: String
public let localeId: String
public let busyUtteranceId: UUID?
public let sessionExpiresAt: TimeInterval?
public init(
protocolVersion: Int = 1,
sessionId: UUID?,
ready: Bool,
reason: Reason,
heartbeatAt: TimeInterval = Date().timeIntervalSince1970,
readyAt: TimeInterval? = nil,
audioProofAt: TimeInterval? = nil,
engineMode: String,
localeId: String,
busyUtteranceId: UUID? = nil,
sessionExpiresAt: TimeInterval? = nil
) {
self.protocolVersion = protocolVersion
self.sessionId = sessionId
self.ready = ready
self.reason = reason
self.heartbeatAt = heartbeatAt
self.readyAt = readyAt
self.audioProofAt = audioProofAt
self.engineMode = engineMode
self.localeId = localeId
self.busyUtteranceId = busyUtteranceId
self.sessionExpiresAt = sessionExpiresAt
}
}
public struct FlowTranscriptionError: Equatable, Sendable {
public let message: String
public let kind: FlowSessionKeys.TranscriptionErrorKind
@@ -36,10 +188,105 @@ public enum FlowSessionBridge {
}
}
private static func encode<T: Encodable>(_ value: T) -> Data? {
try? JSONEncoder().encode(value)
}
private static func decode<T: Decodable>(_ type: T.Type, from data: Data?) -> T? {
guard let data else { return nil }
return try? JSONDecoder().decode(type, from: data)
}
/// Keyboard/read side: refresh App Group defaults after the extension was
/// suspended so decisions are not based on stale in-process caches.
public static func reloadFromDisk(defaults: UserDefaults? = nil) {
let store = resolvedDefaults(defaults)
if Thread.isMainThread {
store.synchronize()
}
}
// MARK: - Typed Flow protocol
public static func writeCommand(_ command: FlowCommand, defaults: UserDefaults? = nil) {
let store = resolvedDefaults(defaults)
if let data = encode(command) {
store.set(data, forKey: FlowSessionKeys.flowCommandPayload)
}
flush(store)
FlowSessionDarwin.postCommandChanged()
}
public static func latestCommand(defaults: UserDefaults? = nil) -> FlowCommand? {
let store = resolvedDefaults(defaults)
return decode(FlowCommand.self, from: store.data(forKey: FlowSessionKeys.flowCommandPayload))
}
public static func writeResult(_ result: FlowResult, defaults: UserDefaults? = nil) {
let store = resolvedDefaults(defaults)
if let data = encode(result) {
store.set(data, forKey: FlowSessionKeys.flowResultPayload)
}
flush(store)
FlowSessionDarwin.postTranscriptionChanged()
}
public static func latestResult(defaults: UserDefaults? = nil) -> FlowResult? {
let store = resolvedDefaults(defaults)
return decode(FlowResult.self, from: store.data(forKey: FlowSessionKeys.flowResultPayload))
}
public static func clearResult(defaults: UserDefaults? = nil) {
let store = resolvedDefaults(defaults)
store.removeObject(forKey: FlowSessionKeys.flowResultPayload)
flush(store)
}
public static func writeAck(_ ack: FlowAck, defaults: UserDefaults? = nil) {
let store = resolvedDefaults(defaults)
if let data = encode(ack) {
store.set(data, forKey: FlowSessionKeys.flowAckPayload)
}
flush(store)
}
public static func latestAck(defaults: UserDefaults? = nil) -> FlowAck? {
let store = resolvedDefaults(defaults)
return decode(FlowAck.self, from: store.data(forKey: FlowSessionKeys.flowAckPayload))
}
public static func writeReadySnapshot(_ snapshot: FlowReadySnapshot, defaults: UserDefaults? = nil) {
let store = resolvedDefaults(defaults)
if let data = encode(snapshot) {
store.set(data, forKey: FlowSessionKeys.flowReadyPayload)
}
if snapshot.ready {
store.set(true, forKey: FlowSessionKeys.flowHostReady)
if let readyAt = snapshot.readyAt {
store.set(readyAt, forKey: FlowSessionKeys.flowHostReadyAt)
}
} else {
clearHostReady(defaults: store, notify: false)
store.removeObject(forKey: FlowSessionKeys.flowReadyPayload)
}
if let expires = snapshot.sessionExpiresAt {
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
}
store.set(snapshot.heartbeatAt, forKey: FlowSessionKeys.flowHeartbeat)
flush(store)
FlowSessionDarwin.postHostReadyChanged()
}
public static func readySnapshot(defaults: UserDefaults? = nil) -> FlowReadySnapshot? {
let store = resolvedDefaults(defaults)
return decode(FlowReadySnapshot.self, from: store.data(forKey: FlowSessionKeys.flowReadyPayload))
}
// MARK: - Session lifecycle (host app)
public static func markSessionActive(
duration: TimeInterval? = nil,
sessionId: UUID? = nil,
defaults: UserDefaults? = nil
) {
let store = resolvedDefaults(defaults)
@@ -50,8 +297,26 @@ public enum FlowSessionBridge {
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
store.set(now, forKey: FlowSessionKeys.lastActivityAt)
writeHeartbeat(defaults: store)
setRecordingState(.idle, defaults: store)
clearTranscription(defaults: store)
store.removeObject(forKey: FlowSessionKeys.flowCommandPayload)
store.removeObject(forKey: FlowSessionKeys.flowResultPayload)
store.removeObject(forKey: FlowSessionKeys.flowAckPayload)
if let sessionId {
let snapshot = FlowReadySnapshot(
sessionId: sessionId,
ready: false,
reason: .starting,
heartbeatAt: now,
engineMode: AppGroupConfiguration.load(fromAvailable: store).engineMode,
localeId: AppGroupConfiguration.load(fromAvailable: store).localeId,
sessionExpiresAt: expires
)
if let data = encode(snapshot) {
store.set(data, forKey: FlowSessionKeys.flowReadyPayload)
}
} else {
store.removeObject(forKey: FlowSessionKeys.flowReadyPayload)
}
flush(store)
}
@@ -60,14 +325,22 @@ public enum FlowSessionBridge {
store.set(false, forKey: FlowSessionKeys.flowSessionActive)
store.removeObject(forKey: FlowSessionKeys.flowSessionExpires)
store.removeObject(forKey: FlowSessionKeys.flowHeartbeat)
setRecordingState(.idle, defaults: store)
clearTranscription(defaults: store)
store.removeObject(forKey: FlowSessionKeys.flowCommandPayload)
store.removeObject(forKey: FlowSessionKeys.flowResultPayload)
store.removeObject(forKey: FlowSessionKeys.flowAckPayload)
store.removeObject(forKey: FlowSessionKeys.flowReadyPayload)
clearHostReady(defaults: store, notify: false)
flush(store)
}
public static func writeHeartbeat(defaults: UserDefaults? = nil) {
let store = resolvedDefaults(defaults)
store.set(Date().timeIntervalSince1970, forKey: FlowSessionKeys.flowHeartbeat)
let now = Date().timeIntervalSince1970
store.set(now, forKey: FlowSessionKeys.flowHeartbeat)
if store.bool(forKey: FlowSessionKeys.flowHostReady) {
store.set(now, forKey: FlowSessionKeys.flowHostReadyAt)
}
flush(store)
}
@@ -118,8 +391,7 @@ public enum FlowSessionBridge {
// MARK: - Session validity (keyboard)
/// True when the App Group session contract is still valid (not expired).
/// Does **not** mean the host process is alive use `isHostReachable()` for
/// recording gates and "session ready" UI.
/// Does **not** mean the host can accept utterances use `isHostReady()`.
public static func isSessionActive(defaults: UserDefaults? = nil) -> Bool {
let store = resolvedDefaults(defaults)
guard store.bool(forKey: FlowSessionKeys.flowSessionActive) else { return false }
@@ -137,8 +409,8 @@ public enum FlowSessionBridge {
}
/// True when the host app recently wrote a heartbeat (foreground or
/// actively processing). Gating record / "session ready" UI must use this,
/// not `isSessionActive()` alone.
/// actively processing). Use for zombie / disconnect detection **not**
/// for mic-ready UI; prefer `isHostReady()`.
public static func isHostReachable(defaults: UserDefaults? = nil) -> Bool {
let store = resolvedDefaults(defaults)
guard isSessionActive(defaults: store) else { return false }
@@ -146,6 +418,53 @@ public enum FlowSessionBridge {
return staleness <= FlowSessionKeys.heartbeatStaleInterval
}
// MARK: - Host ready contract (host app keyboard)
/// Host app: publish whether Flow can accept a new utterance right now.
public static func setHostReady(
_ ready: Bool,
defaults: UserDefaults? = nil,
notify: Bool = true
) {
let store = resolvedDefaults(defaults)
if ready {
let now = Date().timeIntervalSince1970
store.set(true, forKey: FlowSessionKeys.flowHostReady)
store.set(now, forKey: FlowSessionKeys.flowHostReadyAt)
writeHeartbeat(defaults: store)
} else {
clearHostReady(defaults: store, notify: false)
}
flush(store)
if notify {
FlowSessionDarwin.postHostReadyChanged()
}
}
/// True when the host has published a fresh ready contract (stricter than heartbeat alone).
public static func isHostReady(defaults: UserDefaults? = nil) -> Bool {
let store = resolvedDefaults(defaults)
if let snapshot = readySnapshot(defaults: store) {
guard snapshot.ready else { return false }
guard isHostReachable(defaults: store) else { return false }
if let readyAt = snapshot.readyAt {
let skew = abs(snapshot.heartbeatAt - readyAt)
guard skew <= FlowSessionKeys.hostReadyMaxHeartbeatSkew else { return false }
}
return true
}
guard isHostReachable(defaults: store) else { return false }
return store.bool(forKey: FlowSessionKeys.flowHostReady)
}
private static func clearHostReady(defaults: UserDefaults, notify: Bool) {
defaults.removeObject(forKey: FlowSessionKeys.flowHostReady)
defaults.removeObject(forKey: FlowSessionKeys.flowHostReadyAt)
if notify {
FlowSessionDarwin.postHostReadyChanged()
}
}
/// True when the session contract flag is still set but the host heartbeat
/// proves the process is gone (reboot, force-quit, long suspend).
public static func isHostStale(
@@ -342,10 +661,15 @@ public enum FlowSessionBridge {
store.removeObject(forKey: FlowSessionKeys.flowHeartbeat)
store.removeObject(forKey: FlowSessionKeys.keyboardRecordingState)
store.removeObject(forKey: FlowSessionKeys.transcriptionLanguage)
store.removeObject(forKey: FlowSessionKeys.flowCommandPayload)
store.removeObject(forKey: FlowSessionKeys.flowResultPayload)
store.removeObject(forKey: FlowSessionKeys.flowAckPayload)
store.removeObject(forKey: FlowSessionKeys.flowReadyPayload)
clearTranscription(defaults: store)
store.removeObject(forKey: FlowSessionKeys.audioLevels)
store.removeObject(forKey: FlowSessionKeys.pendingHostBundleId)
store.removeObject(forKey: FlowSessionKeys.lastActivityAt)
clearHostReady(defaults: store, notify: false)
flush(store)
}
@@ -8,8 +8,12 @@ import Foundation
public enum FlowSessionDarwin {
public static let notificationName = "com.osgkeyboard.flow.session.changed"
/// Posted when the keyboard writes a command for the host app.
public static let commandNotificationName = "com.osgkeyboard.flow.command.changed"
/// Posted when the host app writes a transcription result or error.
public static let transcriptionNotificationName = "com.osgkeyboard.flow.transcription.changed"
/// Posted when the host app publishes or clears the ready contract.
public static let hostReadyNotificationName = "com.osgkeyboard.flow.host.ready.changed"
public static func postSessionChanged() {
CFNotificationCenterPostNotification(
@@ -21,6 +25,16 @@ public enum FlowSessionDarwin {
)
}
public static func postCommandChanged() {
CFNotificationCenterPostNotification(
CFNotificationCenterGetDarwinNotifyCenter(),
CFNotificationName(commandNotificationName as CFString),
nil,
nil,
true
)
}
public static func postTranscriptionChanged() {
CFNotificationCenterPostNotification(
CFNotificationCenterGetDarwinNotifyCenter(),
@@ -30,6 +44,16 @@ public enum FlowSessionDarwin {
true
)
}
public static func postHostReadyChanged() {
CFNotificationCenterPostNotification(
CFNotificationCenterGetDarwinNotifyCenter(),
CFNotificationName(hostReadyNotificationName as CFString),
nil,
nil,
true
)
}
}
/// Observes Darwin notifications on a background thread; invokes
@@ -7,9 +7,17 @@
import Foundation
public enum FlowSessionKeys {
public static let flowCommandPayload = "flow.commandPayload.v1"
public static let flowResultPayload = "flow.resultPayload.v1"
public static let flowAckPayload = "flow.ackPayload.v1"
public static let flowReadyPayload = "flow.readyPayload.v1"
public static let flowSessionActive = "flow.flowSessionActive"
public static let flowSessionExpires = "flow.flowSessionExpires"
public static let flowHeartbeat = "flow.flowHeartbeat"
/// Host-published contract: capture + polling idle and able to accept utterances.
public static let flowHostReady = "flow.flowHostReady"
/// Wall-clock timestamp paired with `flowHostReady` (seconds since 1970).
public static let flowHostReadyAt = "flow.flowHostReadyAt"
public static let keyboardRecordingState = "flow.keyboardRecordingState"
public static let transcriptionLanguage = "flow.transcriptionLanguage"
public static let transcriptionResult = "flow.transcriptionResult"
@@ -29,6 +37,9 @@ public enum FlowSessionKeys {
/// Heartbeat older than this host is not actively reachable for recording.
public static let heartbeatStaleInterval: TimeInterval = 3
/// `flowHostReadyAt` must be within this window of the latest heartbeat.
public static let hostReadyMaxHeartbeatSkew: TimeInterval = 5
/// Session flag still set but heartbeat older than this host process is
/// dead (force-quit, reboot). Keyboard / host should clear persisted state.
public static let heartbeatZombieInterval: TimeInterval = 60
@@ -67,7 +78,7 @@ public enum FlowSessionKeys {
}
/// Structured host keyboard transcription failure kind.
public enum TranscriptionErrorKind: String, Sendable, Equatable {
public enum TranscriptionErrorKind: String, Sendable, Equatable, Codable {
case noSpeech
case recognitionInterrupted
case audioUnavailable
@@ -2,7 +2,8 @@
// OSGKeyboard · Shared
//
// Single entry point for iCloud KVS sync in the main app: preferences
// toggles, settings payload, and personal dictionary.
// toggles, usage statistics, settings payload, speech history, and
// personal dictionary.
import Foundation
@@ -14,18 +15,28 @@ public final class AppCloudSync {
private let makeStore: () -> AppGroupStore
private let settingsSync: SettingsCloudSync
private let dictionarySync: PersonalDictionaryCloudSync
private let usageStatisticsSync: UsageStatisticsCloudSync
private let speechHistorySync: SpeechHistoryCloudSync
private var externalChangeObserver: NSObjectProtocol?
public init(
kvs: UbiquitousKeyValueStoreing = NSUbiquitousKeyValueStore.default,
makeStore: @escaping () -> AppGroupStore = { AppGroupStore() },
historyDefaults: @escaping () -> UserDefaults = { .standard },
settingsSync: SettingsCloudSync? = nil,
dictionarySync: PersonalDictionaryCloudSync? = nil
dictionarySync: PersonalDictionaryCloudSync? = nil,
usageStatisticsSync: UsageStatisticsCloudSync? = nil,
speechHistorySync: SpeechHistoryCloudSync? = nil
) {
self.kvs = kvs
self.makeStore = makeStore
self.settingsSync = settingsSync ?? SettingsCloudSync(kvs: kvs, makeStore: makeStore)
self.settingsSync = settingsSync
?? SettingsCloudSync(kvs: kvs, makeStore: makeStore, historyDefaults: historyDefaults)
self.dictionarySync = dictionarySync ?? PersonalDictionaryCloudSync(kvs: kvs, makeStore: makeStore)
self.usageStatisticsSync = usageStatisticsSync
?? UsageStatisticsCloudSync(kvs: kvs, makeStore: makeStore)
self.speechHistorySync = speechHistorySync
?? SpeechHistoryCloudSync(kvs: kvs, makeStore: makeStore, historyDefaults: historyDefaults)
}
public func startObservingExternalChanges() {
@@ -62,9 +73,28 @@ public final class AppCloudSync {
)
await settingsSync.pullAndMergeIfEnabled()
await usageStatisticsSync.pullAndMergeIfEnabled()
await speechHistorySync.pullAndMergeIfEnabled()
await dictionarySync.pullAndMergeIfEnabled()
}
/// Low-risk manual sync: pull remote changes, merge, then push local state.
public func syncNow() async throws {
let store = makeStore()
await pullAllIfEnabled()
if store.settingsICloudSyncEnabled {
try await settingsSync.pushLocalIfEnabled()
try await usageStatisticsSync.pushLocalIfEnabled()
try await speechHistorySync.pushLocalIfEnabled()
}
if store.personalDictionaryICloudSyncEnabled {
try await dictionarySync.pushLocalIfEnabled(store.personalDictionary)
}
}
public var settingsSyncService: SettingsCloudSync { settingsSync }
public var dictionarySyncService: PersonalDictionaryCloudSync { dictionarySync }
public var usageStatisticsSyncService: UsageStatisticsCloudSync { usageStatisticsSync }
public var speechHistorySyncService: SpeechHistoryCloudSync { speechHistorySync }
}
@@ -0,0 +1,19 @@
// CloudSyncContext.swift
// OSGKeyboard · Shared
//
// Injectable AppCloudSync instance so iOS and Mac share one sync graph.
import Foundation
@MainActor
public enum CloudSyncContext {
private static var configured: AppCloudSync?
public static var shared: AppCloudSync {
configured ?? AppCloudSync.shared
}
public static func configure(_ sync: AppCloudSync) {
configured = sync
}
}
@@ -1,8 +1,8 @@
// SettingsCloudSync.swift
// OSGKeyboard · Shared
//
// Mirrors user-facing app settings through iCloud KVS. API keys stay
// in Keychain and are never uploaded.
// Mirrors user-facing app settings through iCloud KVS (`appSettings.v2`)
// with per-field merge. API keys sync via iCloud Keychain never KVS.
import Foundation
@@ -22,17 +22,21 @@ public enum SettingsCloudSyncError: Error, Equatable, Sendable {
public final class SettingsCloudSync {
public static let shared = SettingsCloudSync()
public static let kvsKey = "appSettings.v1"
public static let kvsKey = SyncedAppSettingsV2.kvsKey
public static let legacyKVSKey = SyncedAppSettings.legacyKVSKey
private let kvs: UbiquitousKeyValueStoreing
private let makeStore: () -> AppGroupStore
private let historyDefaults: () -> UserDefaults
public init(
kvs: UbiquitousKeyValueStoreing = NSUbiquitousKeyValueStore.default,
makeStore: @escaping () -> AppGroupStore = { AppGroupStore() }
makeStore: @escaping () -> AppGroupStore = { AppGroupStore() },
historyDefaults: @escaping () -> UserDefaults = { .standard }
) {
self.kvs = kvs
self.makeStore = makeStore
self.historyDefaults = historyDefaults
}
public func pullAndMergeIfEnabled() async {
@@ -44,8 +48,15 @@ public final class SettingsCloudSync {
public func pushLocalIfEnabled() async throws {
let store = makeStore()
guard store.settingsICloudSyncEnabled else { return }
let local = SyncedAppSettings.from(configuration: store.configurationSnapshot())
try push(local)
let deviceID = SyncDeviceID.current(defaults: store.defaults)
let config = store.configurationSnapshot()
var local = loadLocalPayload(from: store.defaults, configuration: config, deviceID: deviceID)
local = local.patchLocalChanges(from: config, deviceID: deviceID)
saveLocalPayload(local, to: store.defaults)
let remote = loadRemote()
let toPush = remote.map { SyncedAppSettingsV2.merge(local: local, remote: $0) } ?? local
try push(toPush)
}
public func enableSync() async throws {
@@ -57,12 +68,26 @@ public final class SettingsCloudSync {
store: store
)
let local = SyncedAppSettings.from(configuration: store.configurationSnapshot())
Keychain.migrateLocalKeysToICloud()
let deviceID = SyncDeviceID.current(defaults: store.defaults)
let config = store.configurationSnapshot()
var local = loadLocalPayload(from: store.defaults, configuration: config, deviceID: deviceID)
local = local.patchLocalChanges(from: config, deviceID: deviceID)
let remote = loadRemote() ?? local
let merged = SyncedAppSettings.merge(local: local, remote: remote)
let merged = SyncedAppSettingsV2.merge(local: local, remote: remote)
apply(merged, to: store, postNotification: false)
try push(merged)
NotificationCenter.default.post(name: .settingsDidSyncFromCloud, object: nil)
let statisticsSync = UsageStatisticsCloudSync(kvs: kvs, makeStore: makeStore)
try await statisticsSync.mergeAndPushIfEnabled()
let historySync = SpeechHistoryCloudSync(
kvs: kvs,
makeStore: makeStore,
historyDefaults: historyDefaults
)
try await historySync.mergeAndPushIfEnabled()
}
public func disableSync() {
@@ -75,17 +100,19 @@ public final class SettingsCloudSync {
guard store.settingsICloudSyncEnabled else { return }
guard let remote = loadRemote() else { return }
let local = SyncedAppSettings.from(
configuration: store.configurationSnapshot(),
updatedAt: store.settingsCloudUpdatedAt ?? .distantPast
)
let merged = SyncedAppSettings.merge(local: local, remote: remote)
guard merged != local else { return }
let deviceID = SyncDeviceID.current(defaults: store.defaults)
let config = store.configurationSnapshot()
var local = loadLocalPayload(from: store.defaults, configuration: config, deviceID: deviceID)
local = local.patchLocalChanges(from: config, deviceID: deviceID)
let merged = SyncedAppSettingsV2.merge(local: local, remote: remote)
var trial = config
merged.applying(to: &trial)
guard trial != config else { return }
apply(merged, to: store, postNotification: true)
}
public func push(_ settings: SyncedAppSettings) throws {
public func push(_ settings: SyncedAppSettingsV2) throws {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
guard let data = try? encoder.encode(settings) else {
@@ -95,12 +122,28 @@ public final class SettingsCloudSync {
_ = kvs.synchronize()
}
public func loadRemote() -> SyncedAppSettings? {
guard let data = kvs.data(forKey: Self.kvsKey) else { return nil }
return try? decode(data)
public func loadRemote() -> SyncedAppSettingsV2? {
if let data = kvs.data(forKey: Self.kvsKey) {
return try? decodeV2(data)
}
guard let legacyData = kvs.data(forKey: Self.legacyKVSKey),
let legacy = try? decodeLegacy(legacyData) else {
return nil
}
let deviceID = SyncDeviceID.current()
return SyncedAppSettingsV2.migrated(from: legacy, deviceID: deviceID)
}
public func decode(_ data: Data) throws -> SyncedAppSettings {
public func decodeV2(_ data: Data) throws -> SyncedAppSettingsV2 {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
guard let settings = try? decoder.decode(SyncedAppSettingsV2.self, from: data) else {
throw SettingsCloudSyncError.decodeFailed
}
return settings
}
public func decodeLegacy(_ data: Data) throws -> SyncedAppSettings {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
guard let settings = try? decoder.decode(SyncedAppSettings.self, from: data) else {
@@ -110,18 +153,42 @@ public final class SettingsCloudSync {
}
private func apply(
_ settings: SyncedAppSettings,
_ settings: SyncedAppSettingsV2,
to store: AppGroupStore,
postNotification: Bool
) {
var config = store.configurationSnapshot()
settings.applying(to: &config)
store.saveConfiguration(config, settingsCloudUpdatedAt: settings.updatedAt)
store.saveConfiguration(config, settingsCloudUpdatedAt: settings.latestUpdatedAt)
saveLocalPayload(settings, to: store.defaults)
if postNotification {
AppGroupConfigDarwin.postConfigChanged()
NotificationCenter.default.post(name: .settingsDidSyncFromCloud, object: nil)
}
}
private func loadLocalPayload(
from defaults: UserDefaults,
configuration: AppGroupConfiguration,
deviceID: String
) -> SyncedAppSettingsV2 {
if let data = defaults.data(forKey: AppGroupConfiguration.Keys.settingsCloudPayloadV2),
let payload = try? JSONDecoder().decode(SyncedAppSettingsV2.self, from: data) {
return payload
}
let stamp = defaults.object(forKey: AppGroupConfiguration.Keys.settingsCloudUpdatedAt) as? TimeInterval
let updatedAt = stamp.map { Date(timeIntervalSince1970: $0) } ?? .distantPast
return SyncedAppSettingsV2.seeded(
from: configuration,
deviceID: deviceID,
updatedAt: updatedAt
)
}
private func saveLocalPayload(_ payload: SyncedAppSettingsV2, to defaults: UserDefaults) {
guard let data = try? JSONEncoder().encode(payload) else { return }
defaults.set(data, forKey: AppGroupConfiguration.Keys.settingsCloudPayloadV2)
}
}
private extension AppGroupStore {
@@ -132,6 +199,9 @@ private extension AppGroupStore {
func saveConfiguration(_ configuration: AppGroupConfiguration, settingsCloudUpdatedAt: Date) {
let config = configuration
config.save(to: defaults)
defaults.set(settingsCloudUpdatedAt.timeIntervalSince1970, forKey: AppGroupConfiguration.Keys.settingsCloudUpdatedAt)
defaults.set(
settingsCloudUpdatedAt.timeIntervalSince1970,
forKey: AppGroupConfiguration.Keys.settingsCloudUpdatedAt
)
}
}
@@ -0,0 +1,128 @@
// SpeechHistoryCloudSync.swift
// OSGKeyboard · Shared
//
// Mirrors speech history through iCloud KVS when settings sync is enabled.
import Foundation
public extension Notification.Name {
/// Posted after remote speech history is applied locally.
static let speechHistoryDidSyncFromCloud = Notification.Name(
"com.osgkeyboard.speechHistory.didSyncFromCloud"
)
}
public enum SpeechHistoryCloudSyncError: Error, Equatable, Sendable {
case payloadTooLarge(byteCount: Int)
case encodeFailed
case decodeFailed
}
@MainActor
public final class SpeechHistoryCloudSync {
public static let shared = SpeechHistoryCloudSync()
public static let kvsKey = SyncedSpeechHistory.kvsKey
public static let legacyKVSKey = SyncedSpeechHistory.legacyKVSKey
/// Stay below the ~1 MB per-key KVS limit.
public static let maxPayloadBytes = 900_000
private let kvs: UbiquitousKeyValueStoreing
private let makeStore: () -> AppGroupStore
private let historyDefaults: () -> UserDefaults
public init(
kvs: UbiquitousKeyValueStoreing = NSUbiquitousKeyValueStore.default,
makeStore: @escaping () -> AppGroupStore = { AppGroupStore() },
historyDefaults: @escaping () -> UserDefaults = { .standard }
) {
self.kvs = kvs
self.makeStore = makeStore
self.historyDefaults = historyDefaults
}
public func pullAndMergeIfEnabled() async {
let store = makeStore()
guard store.settingsICloudSyncEnabled else { return }
await pullAndMerge(store: store)
}
public func pushLocalIfEnabled() async throws {
let store = makeStore()
guard store.settingsICloudSyncEnabled else { return }
let local = SpeechHistoryStorage.load(from: historyDefaults())
try push(local)
}
/// Called when settings sync is first enabled to union local + remote history.
public func mergeAndPushIfEnabled() async throws {
let store = makeStore()
guard store.settingsICloudSyncEnabled else { return }
let defaults = historyDefaults()
let local = SpeechHistoryStorage.load(from: defaults)
let remote = loadRemote() ?? local
let merged = SyncedSpeechHistory.merge(local: local, remote: remote)
apply(merged, to: defaults, postNotification: false)
try push(merged)
NotificationCenter.default.post(name: .speechHistoryDidSyncFromCloud, object: nil)
}
public func pullAndMerge(store: AppGroupStore) async {
guard store.settingsICloudSyncEnabled else { return }
guard let remote = loadRemote() else { return }
let defaults = historyDefaults()
let local = SpeechHistoryStorage.load(from: defaults)
let merged = SyncedSpeechHistory.merge(local: local, remote: remote)
guard merged != local else { return }
apply(merged, to: defaults, postNotification: true)
}
public func push(_ history: SyncedSpeechHistory) throws {
let data = try encode(history)
kvs.set(data, forKey: Self.kvsKey)
_ = kvs.synchronize()
}
public func loadRemote() -> SyncedSpeechHistory? {
if let data = kvs.data(forKey: Self.kvsKey) {
return try? decode(data)
}
guard let legacyData = kvs.data(forKey: Self.legacyKVSKey) else { return nil }
return try? decode(legacyData)
}
public func encode(_ history: SyncedSpeechHistory) throws -> Data {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
guard let data = try? encoder.encode(history) else {
throw SpeechHistoryCloudSyncError.encodeFailed
}
guard data.count <= Self.maxPayloadBytes else {
throw SpeechHistoryCloudSyncError.payloadTooLarge(byteCount: data.count)
}
return data
}
public func decode(_ data: Data) throws -> SyncedSpeechHistory {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
guard let history = try? decoder.decode(SyncedSpeechHistory.self, from: data) else {
throw SpeechHistoryCloudSyncError.decodeFailed
}
return history
}
private func apply(
_ history: SyncedSpeechHistory,
to defaults: UserDefaults,
postNotification: Bool
) {
SpeechHistoryStorage.save(history, to: defaults)
if postNotification {
NotificationCenter.default.post(name: .speechHistoryDidSyncFromCloud, object: nil)
}
}
}
@@ -0,0 +1,20 @@
// SyncDeviceID.swift
// OSGKeyboard · Shared
//
// Stable per-install identifier for per-field / per-device iCloud merge.
import Foundation
public enum SyncDeviceID {
private static let defaultsKey = "sync.deviceID.v1"
/// Returns a stable device id stored in the active defaults suite.
public static func current(defaults: UserDefaults = AppGroupStore().defaults) -> String {
if let existing = defaults.string(forKey: defaultsKey), !existing.isEmpty {
return existing
}
let created = UUID().uuidString
defaults.set(created, forKey: defaultsKey)
return created
}
}
@@ -0,0 +1,133 @@
// UsageStatisticsCloudSync.swift
// OSGKeyboard · Shared
//
// Mirrors cumulative usage statistics through iCloud KVS (`usageStatistics.v2`)
// using per-device G-Counter merge when settings sync is enabled.
import Foundation
public extension Notification.Name {
/// Posted after remote usage statistics are applied locally.
static let usageStatisticsDidSyncFromCloud = Notification.Name(
"com.osgkeyboard.usageStatistics.didSyncFromCloud"
)
}
public enum UsageStatisticsCloudSyncError: Error, Equatable, Sendable {
case encodeFailed
case decodeFailed
}
@MainActor
public final class UsageStatisticsCloudSync {
public static let shared = UsageStatisticsCloudSync()
public static let kvsKey = SyncedUsageStatisticsV2.kvsKey
public static let legacyKVSKey = SyncedUsageStatisticsStorage.legacyStorageKey
private let kvs: UbiquitousKeyValueStoreing
private let makeStore: () -> AppGroupStore
public init(
kvs: UbiquitousKeyValueStoreing = NSUbiquitousKeyValueStore.default,
makeStore: @escaping () -> AppGroupStore = { AppGroupStore() }
) {
self.kvs = kvs
self.makeStore = makeStore
}
public func pullAndMergeIfEnabled() async {
let store = makeStore()
guard store.settingsICloudSyncEnabled else { return }
await pullAndMerge(store: store)
}
public func pushLocalIfEnabled() async throws {
let store = makeStore()
guard store.settingsICloudSyncEnabled else { return }
let local = SyncedUsageStatisticsStorage.load(from: store.defaults)
try push(local)
}
/// Called when settings sync is first enabled to union local + remote totals.
public func mergeAndPushIfEnabled() async throws {
let store = makeStore()
guard store.settingsICloudSyncEnabled else { return }
let local = SyncedUsageStatisticsStorage.load(from: store.defaults)
let remote = loadRemote() ?? local
let merged = SyncedUsageStatisticsV2.merge(local: local, remote: remote)
apply(merged, to: store.defaults, postNotification: false)
try push(merged)
NotificationCenter.default.post(name: .usageStatisticsDidSyncFromCloud, object: nil)
}
public func pullAndMerge(store: AppGroupStore) async {
guard store.settingsICloudSyncEnabled else { return }
guard let remote = loadRemote() else { return }
let local = SyncedUsageStatisticsStorage.load(from: store.defaults)
let merged = SyncedUsageStatisticsV2.merge(local: local, remote: remote)
guard merged != local else { return }
apply(merged, to: store.defaults, postNotification: true)
}
public func push(_ stats: SyncedUsageStatisticsV2) throws {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
guard let data = try? encoder.encode(stats) else {
throw UsageStatisticsCloudSyncError.encodeFailed
}
kvs.set(data, forKey: Self.kvsKey)
_ = kvs.synchronize()
}
/// Removes the cumulative-stats payload from iCloud KVS. Used by the
/// one-time cleanup that clears data corrupted by the pre-fix
/// double-counting bug so it can't be pulled back onto other devices.
public func purgeRemote() {
kvs.set(Data?.none, forKey: Self.kvsKey)
kvs.set(Data?.none, forKey: Self.legacyKVSKey)
_ = kvs.synchronize()
}
public func loadRemote() -> SyncedUsageStatisticsV2? {
if let data = kvs.data(forKey: Self.kvsKey) {
return try? decodeV2(data)
}
guard let legacyData = kvs.data(forKey: Self.legacyKVSKey) else { return nil }
let deviceID = SyncDeviceID.current()
guard let legacy = try? decodeLegacy(legacyData) else { return nil }
return SyncedUsageStatisticsV2.migrated(from: legacy, deviceID: deviceID)
}
public func decodeV2(_ data: Data) throws -> SyncedUsageStatisticsV2 {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
guard let stats = try? decoder.decode(SyncedUsageStatisticsV2.self, from: data) else {
throw UsageStatisticsCloudSyncError.decodeFailed
}
return stats
}
public func decodeLegacy(_ data: Data) throws -> UsageStatistics {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
guard let stats = try? decoder.decode(UsageStatistics.self, from: data) else {
throw UsageStatisticsCloudSyncError.decodeFailed
}
return stats
}
private func apply(
_ stats: SyncedUsageStatisticsV2,
to defaults: UserDefaults,
postNotification: Bool
) {
SyncedUsageStatisticsStorage.save(stats, to: defaults)
if postNotification {
NotificationCenter.default.post(name: .usageStatisticsDidSyncFromCloud, object: nil)
}
}
}
@@ -82,7 +82,10 @@ public final class KeyboardState: ObservableObject {
@Published public var utteranceRemainingSeconds: Int = Int(FlowSessionKeys.maxUtteranceDuration)
/// Whether the host app's Flow voice session is live and reachable (fresh
/// heartbeat). Do not use the App Group session flag alone for UI gating.
/// Prefer `micVoiceAvailability` for mic color and tap behavior.
@Published public var flowSessionActive: Bool = false
/// Unified mic color / tap / hint source for the keyboard extension.
@Published public var micVoiceAvailability: MicVoiceAvailability = .unavailable(.hostNotReady)
/// When true, the mic is intentionally disabled (e.g. cloud engine
/// selected but the provider-specific API key is missing).
@Published public var micDisabled: Bool = false

Some files were not shown because too many files have changed in this diff Show More