feat(macos): add macOS menu-bar app and harden cross-device iCloud sync

Introduce a standalone macOS menu-bar app (OSGKeyboardMac) that reuses the
platform-agnostic OSGKeyboardShared core: record -> cloud/local ASR -> polish
-> insert. Local mode uses Qwen3-ASR via mlx-swift-asr (macOS 15+, Apple
Silicon); iOS targets stay zero-SPM.

Harden iCloud sync for multi-device correctness:
- Per-field settings merge (appSettings.v2) so concurrent edits no longer
  clobber each other's unrelated fields.
- Per-device usage statistics (G-Counter) that sum instead of max().
- Tombstoned dictionary/history merge so deletes propagate and entries can't
  resurrect.
- API keys replicate via iCloud Keychain, never iCloud KVS JSON; pulling a
  legacy blob without key fields no longer wipes local Keychain entries.
- Add a low-risk "Sync Now" action in Settings.

Fix Flow keyboard mic state: stay orange until the host publishes a real ready
contract, share a single MicVoiceAvailability gate, and self-heal stale
cross-process heartbeat jitter instead of getting stuck.

Extract shared storage (SpeechHistoryStore/UsageStatisticsStore,
ConfigurationStore) into OSGKeyboardShared and add tests for the new
sync/merge logic.
This commit is contained in:
Rocky
2026-07-08 18:13:56 +08:00
parent 128aab1b02
commit c2f07bd8d2
99 changed files with 6735 additions and 740 deletions
+103
View File
@@ -0,0 +1,103 @@
// MacLocalASRService.swift
// OSGKeyboard · Mac
//
// On-device ASR for macOS. Primary: Qwen3-ASR-1.7B (MLX via mlx-swift-asr).
// Falls back to Apple Speech when Qwen3 weights are absent or backend is Apple Speech.
import Foundation
enum MacLocalASRBackend: String, Sendable, CaseIterable {
case qwen3MLX
case appleSpeech
}
enum MacLocalASRError: Error, LocalizedError {
case qwen3ModelMissing
case qwen3LoadFailed(String)
case qwen3InferenceFailed(String)
case speechDenied
case speechFailed(String)
case emptyTranscript
var errorDescription: String? {
switch self {
case .qwen3ModelMissing:
return MacL10n.string("mac.error.qwen3ModelMissing")
case .qwen3LoadFailed(let detail):
return MacL10n.format("mac.error.qwen3LoadFailed", detail)
case .qwen3InferenceFailed(let detail):
return MacL10n.format("mac.error.qwen3InferenceFailed", detail)
case .speechDenied:
return "Speech recognition permission denied"
case .speechFailed(let detail):
return detail
case .emptyTranscript:
return MacL10n.string("mac.error.emptyTranscript")
}
}
}
enum MacLocalASRPreferences {
static let backendKey = "mac.localASR.backend"
static let qwen3ModelPathKey = "mac.localASR.qwen3ModelPath"
static var backend: MacLocalASRBackend {
get {
guard let raw = UserDefaults.standard.string(forKey: backendKey),
let value = MacLocalASRBackend(rawValue: raw) else {
return .qwen3MLX
}
return value
}
set { UserDefaults.standard.set(newValue.rawValue, forKey: backendKey) }
}
static var qwen3ModelPath: String {
get { UserDefaults.standard.string(forKey: qwen3ModelPathKey) ?? defaultQwen3ModelPath }
set { UserDefaults.standard.set(newValue, forKey: qwen3ModelPathKey) }
}
/// Default install location for MLX-converted Qwen3-ASR weights.
static var defaultQwen3ModelPath: String {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return appSupport.appendingPathComponent("OSGKeyboard/models/qwen3-asr-1.7b-mlx", isDirectory: true).path
}
static func qwen3ModelIsInstalled(at path: String = qwen3ModelPath) -> Bool {
var isDir: ObjCBool = false
guard FileManager.default.fileExists(atPath: path, isDirectory: &isDir), isDir.boolValue else {
return false
}
let fm = FileManager.default
let config = (path as NSString).appendingPathComponent("config.json")
let weights = (path as NSString).appendingPathComponent("model.safetensors")
guard fm.fileExists(atPath: config), fm.fileExists(atPath: weights) else {
return false
}
let names = (try? fm.contentsOfDirectory(atPath: path)) ?? []
return names.contains("vocab.json") && names.contains("merges.txt")
}
}
enum MacLocalASRService {
/// Transcribe using the user's preferred local backend with automatic
/// fallback to Apple Speech when Qwen3 weights are not present.
static func transcribe(samples: [Float], locale: Locale) async throws -> String {
let preferQwen3 = MacLocalASRPreferences.backend == .qwen3MLX
if preferQwen3, MacLocalASRPreferences.qwen3ModelIsInstalled() {
do {
return try await MacQwen3LocalASR.transcribe(
samples: samples,
sampleRate: 16_000,
locale: locale,
modelPath: MacLocalASRPreferences.qwen3ModelPath
)
} catch MacLocalASRError.qwen3ModelMissing {
// Fall through to Apple Speech when weights are absent.
} catch {
throw error
}
}
return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale)
}
}