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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user