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