Files
OSGKeyboard/OSGKeyboardShared/Models/UsageStatistics.swift
T
Rocky c2f07bd8d2 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.
2026-07-08 18:13:56 +08:00

87 lines
3.3 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}
}