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
+300
View File
@@ -0,0 +1,300 @@
// MacDictationViewModel.swift
// OSGKeyboard · Mac
//
// Drives the whole macOS window: navigation, recording, hotkey, iCloud-backed
// settings, foreground-app context, and text insertion.
import AppKit
import Combine
import SwiftUI
/// Top-level navigation destinations, mirroring the iOS app's tabs.
enum MacSection: String, CaseIterable, Identifiable {
case dashboard
case history
case dictionary
case settings
var id: String { rawValue }
func title(language: AppUILanguage) -> String {
switch self {
case .dashboard: return MacL10n.string("mac.section.dashboard", language: language)
case .history: return MacL10n.string("mac.section.history", language: language)
case .dictionary: return MacL10n.string("mac.section.dictionary", language: language)
case .settings: return MacL10n.string("mac.section.settings", language: language)
}
}
var systemImage: String {
switch self {
case .dashboard: return "square.grid.2x2"
case .history: return "clock.arrow.circlepath"
case .dictionary: return "character.book.closed"
case .settings: return "gearshape"
}
}
}
@MainActor
final class MacDictationViewModel: ObservableObject {
/// Shared instance so the SwiftUI window and the AppKit menu-bar popover
/// (see `MacAppDelegate`) drive the exact same recording / settings state.
static let shared = MacDictationViewModel()
@Published var selectedSection: MacSection = .dashboard
@Published var isRecording = false
@Published var isProcessing = false
@Published var transcript = ""
@Published var statusMessage = ""
@Published var audioLevel: Float = 0
@Published var sessionSeconds: Int = 0
@Published var foregroundAppName: String?
@Published var dictionaryRevision = 0
@Published var autoPasteEnabled: Bool
@Published var hotkeyEnabled: Bool
@Published var config: ProviderConfig
let defaults: UserDefaults
private let recorder = MacAudioRecorder()
private let hotkeyService = MacHotkeyService()
private var levelTimer: Timer?
private var sessionTimer: Timer?
private var cancellables = Set<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()
warmUpQwen3IfNeeded()
}
/// Pre-load MLX weights + Metal shaders so the first dictation is fast.
func warmUpQwen3IfNeeded() {
guard config.engineMode == "local",
MacLocalASRPreferences.backend == .qwen3MLX,
MacLocalASRPreferences.qwen3ModelIsInstalled() else { return }
let path = MacLocalASRPreferences.qwen3ModelPath
Task.detached(priority: .utility) {
_ = try? await MacQwen3ASREngine.shared.prepareIfNeeded(modelPath: path)
}
}
func reloadConfigFromCloud() {
config.reloadFromPersistedStorage()
statusMessage = MacL10n.string("mac.status.ready", language: config.uiLanguage)
warmUpQwen3IfNeeded()
}
func refreshDictionaryFromCloud() {
dictionaryRevision += 1
}
// MARK: - Derived
var selectableProviders: [LLMProvider] {
LLMProvider.presets.filter {
$0.isUserSelectable && $0.cloudASRStrategy != .localFallback
}
}
var dictionaryTermCount: Int {
_ = dictionaryRevision
return AppGroupStore(defaults: defaults).personalDictionary.entries.count
}
var currentWordCount: Int {
transcript.split { $0 == " " || $0 == "\n" || $0 == "\t" }.count
}
var isCloudMode: Bool { config.engineMode == "cloud" }
var languageLabel: String {
let id = config.localeId.isEmpty ? "zh-CN" : config.localeId
return Locale.current.localizedString(forIdentifier: id) ?? id
}
var sessionTimeLabel: String {
let minutes = sessionSeconds / 60
let seconds = sessionSeconds % 60
if minutes > 0 { return "\(minutes)m \(seconds)s" }
return "\(seconds)s"
}
var qwen3ModelInstalled: Bool {
MacLocalASRPreferences.qwen3ModelIsInstalled()
}
// MARK: - Preferences
func setAutoPasteEnabled(_ enabled: Bool) {
autoPasteEnabled = enabled
defaults.set(enabled, forKey: StoredKeys.autoPaste)
}
func setHotkeyEnabled(_ enabled: Bool) {
hotkeyEnabled = enabled
defaults.set(enabled, forKey: StoredKeys.hotkey)
hotkeyService.setEnabled(enabled)
if enabled { hotkeyService.start() } else { hotkeyService.stop() }
}
func setEngineMode(_ mode: String) {
config.engineMode = mode
if mode == "local" { warmUpQwen3IfNeeded() }
}
// MARK: - Recording
func toggleRecording() {
if isRecording { finishRecording() } else { beginRecording() }
}
func beginRecording() {
guard !isProcessing else { return }
let store = AppGroupStore(defaults: defaults)
MacAppContextService.captureAndPersist(to: store)
refreshForegroundAppName()
do {
try recorder.start()
isRecording = true
transcript = ""
statusMessage = MacL10n.string("mac.status.listening", language: config.uiLanguage)
startTimers()
} catch {
statusMessage = error.localizedDescription
}
}
func finishRecording() {
guard isRecording else { return }
isRecording = false
isProcessing = true
statusMessage = MacL10n.string("mac.status.transcribing", language: config.uiLanguage)
stopTimers()
audioLevel = 0
let samples = recorder.stop()
let store = AppGroupStore(defaults: defaults)
Task { [weak self] in
guard let self else { return }
do {
let text = try await MacDictationPipeline.run(samples: samples, store: store)
self.transcript = text
let pasted = try self.deliver(text)
self.recordUsage(for: text)
self.speechHistory.append(text: text)
self.statusMessage = self.statusAfterDelivery(pasted: pasted)
} catch {
self.statusMessage = error.localizedDescription
}
self.isProcessing = false
}
}
private func deliver(_ text: String) throws -> Bool {
try MacTextInsertionService.insert(text, autoPaste: autoPasteEnabled)
}
private func statusAfterDelivery(pasted: Bool) -> String {
let lang = config.uiLanguage
if autoPasteEnabled, pasted {
return MacL10n.string("mac.status.copiedAndPasted", language: lang)
}
if autoPasteEnabled, !pasted {
return MacL10n.string("mac.status.copied", language: lang)
}
return MacL10n.string("mac.status.copied", language: lang)
}
private func wireHotkeyService() {
hotkeyService.onPressBegan = { [weak self] in
self?.beginRecording()
}
hotkeyService.onPressEnded = { [weak self] in
self?.finishRecording()
}
if hotkeyEnabled { hotkeyService.start() }
}
private func startTimers() {
sessionSeconds = 0
let levelTimer = Timer(timeInterval: 0.05, repeats: true) { [weak self] _ in
guard let self else { return }
Task { @MainActor in self.audioLevel = self.recorder.level() }
}
let sessionTimer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in
guard let self else { return }
Task { @MainActor in self.sessionSeconds += 1 }
}
RunLoop.main.add(levelTimer, forMode: .common)
RunLoop.main.add(sessionTimer, forMode: .common)
self.levelTimer = levelTimer
self.sessionTimer = sessionTimer
}
private func stopTimers() {
levelTimer?.invalidate()
sessionTimer?.invalidate()
levelTimer = nil
sessionTimer = nil
}
private func recordUsage(for text: String) {
usageStatistics.recordUtterance(
text: text,
duration: TimeInterval(sessionSeconds),
wasTranslation: config.isTranslationEffective
)
}
func copyToClipboard(_ text: String) {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(text, forType: .string)
}
func selectProvider(_ provider: LLMProvider) {
config.apply(preset: provider)
}
func refreshForegroundAppName() {
foregroundAppName = MacAppContextService.frontmostApplicationName()
}
}