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:
@@ -7,17 +7,36 @@
|
||||
import Foundation
|
||||
|
||||
extension PersonalDictionary {
|
||||
public static let kvsKeyV2 = "personalDictionary.v2"
|
||||
public static let legacyKVSKey = "personalDictionary.v1"
|
||||
public static let tombstoneRetention: TimeInterval = 90 * 24 * 60 * 60
|
||||
|
||||
/// Merges two dictionary snapshots for cross-device sync.
|
||||
///
|
||||
/// Rules:
|
||||
/// - Apply `clearedAt` and deletion tombstones before entry union.
|
||||
/// - Same `id`: keep the entry with the newer `updatedAt`.
|
||||
/// - Same canonical term (case-insensitive) but different `id`: union
|
||||
/// aliases, take max `usageCount`, keep the newer entry's fields.
|
||||
public static func merge(local: PersonalDictionary, remote: PersonalDictionary) -> PersonalDictionary {
|
||||
let clearedAt = later(of: local.clearedAt, and: remote.clearedAt)
|
||||
var deletedIDs = local.deletedEntryIDs
|
||||
for (id, date) in remote.deletedEntryIDs {
|
||||
if let existing = deletedIDs[id] {
|
||||
deletedIDs[id] = max(existing, date)
|
||||
} else {
|
||||
deletedIDs[id] = date
|
||||
}
|
||||
}
|
||||
deletedIDs = pruneTombstones(deletedIDs, clearedAt: clearedAt)
|
||||
|
||||
var mergedByID: [UUID: Entry] = [:]
|
||||
var canonicalOwner: [String: UUID] = [:]
|
||||
|
||||
func insertOrMerge(_ candidate: Entry) {
|
||||
if deletedIDs[candidate.id] != nil { return }
|
||||
if let clearedAt, candidate.createdAt <= clearedAt { return }
|
||||
|
||||
let key = candidate.term.lowercased()
|
||||
if let existingID = canonicalOwner[key], var existing = mergedByID[existingID] {
|
||||
if candidate.id == existingID {
|
||||
@@ -56,10 +75,51 @@ extension PersonalDictionary {
|
||||
return PersonalDictionary(
|
||||
entries: mergedEntries,
|
||||
version: max(local.version, remote.version) + 1,
|
||||
lastSyncedAt: lastSyncedAt
|
||||
lastSyncedAt: lastSyncedAt,
|
||||
deletedEntryIDs: deletedIDs,
|
||||
clearedAt: clearedAt
|
||||
)
|
||||
}
|
||||
|
||||
public mutating func recordDeletion(of entryID: UUID, at date: Date = Date()) {
|
||||
deletedEntryIDs[entryID] = date
|
||||
entries.removeAll { $0.id == entryID }
|
||||
}
|
||||
|
||||
public mutating func recordClearAll(at date: Date = Date()) {
|
||||
entries.removeAll()
|
||||
clearedAt = date
|
||||
}
|
||||
|
||||
public mutating func pruneTombstonesIfNeeded() {
|
||||
deletedEntryIDs = Self.pruneTombstones(deletedEntryIDs, clearedAt: clearedAt)
|
||||
}
|
||||
|
||||
private static func pruneTombstones(
|
||||
_ tombstones: [UUID: Date],
|
||||
clearedAt: Date?
|
||||
) -> [UUID: Date] {
|
||||
let cutoff = Date().addingTimeInterval(-tombstoneRetention)
|
||||
return tombstones.filter { _, deletedAt in
|
||||
if deletedAt < cutoff { return false }
|
||||
if let clearedAt, deletedAt <= clearedAt { return false }
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private static func later(of lhs: Date?, and rhs: Date?) -> Date? {
|
||||
switch (lhs, rhs) {
|
||||
case let (left?, right?):
|
||||
return max(left, right)
|
||||
case (nil, let right?):
|
||||
return right
|
||||
case (let left?, nil):
|
||||
return left
|
||||
case (nil, nil):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolveEntryConflict(existing: Entry, incoming: Entry) -> Entry {
|
||||
incoming.updatedAt >= existing.updatedAt ? incoming : existing
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user