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
@@ -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 {