c2f07bd8d2
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.
152 lines
5.5 KiB
Swift
152 lines
5.5 KiB
Swift
// PersonalDictionary+Merging.swift
|
|
// OSGKeyboard · Shared
|
|
//
|
|
// Deterministic merge rules for iCloud KVS sync. Pure logic — no
|
|
// NSUbiquitousKeyValueStore dependency so unit tests stay hermetic.
|
|
|
|
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 {
|
|
mergedByID[existingID] = resolveEntryConflict(existing: existing, incoming: candidate)
|
|
return
|
|
}
|
|
existing = mergeSameTerm(existing: existing, incoming: candidate)
|
|
mergedByID[existingID] = existing
|
|
return
|
|
}
|
|
|
|
if let existing = mergedByID[candidate.id] {
|
|
mergedByID[candidate.id] = resolveEntryConflict(existing: existing, incoming: candidate)
|
|
canonicalOwner[key] = candidate.id
|
|
return
|
|
}
|
|
|
|
mergedByID[candidate.id] = candidate
|
|
canonicalOwner[key] = candidate.id
|
|
}
|
|
|
|
for entry in local.entries { insertOrMerge(entry) }
|
|
for entry in remote.entries { insertOrMerge(entry) }
|
|
|
|
let mergedEntries = mergedByID.values.sorted {
|
|
if $0.updatedAt != $1.updatedAt {
|
|
return $0.updatedAt > $1.updatedAt
|
|
}
|
|
return $0.term.localizedCaseInsensitiveCompare($1.term) == .orderedAscending
|
|
}
|
|
|
|
let lastSyncedAt = [local.lastSyncedAt, remote.lastSyncedAt]
|
|
.compactMap { $0 }
|
|
.max()
|
|
|
|
return PersonalDictionary(
|
|
entries: mergedEntries,
|
|
version: max(local.version, remote.version) + 1,
|
|
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
|
|
}
|
|
|
|
private static func mergeSameTerm(existing: Entry, incoming: Entry) -> Entry {
|
|
let winner = incoming.updatedAt >= existing.updatedAt ? incoming : existing
|
|
let loser = winner.id == existing.id ? incoming : existing
|
|
var merged = winner
|
|
merged.aliases = unionAliases(
|
|
existing: winner.aliases,
|
|
incoming: loser.aliases,
|
|
excludingTerm: winner.term
|
|
)
|
|
merged.usageCount = max(winner.usageCount, loser.usageCount)
|
|
merged.updatedAt = max(winner.updatedAt, loser.updatedAt)
|
|
return merged
|
|
}
|
|
|
|
private static func unionAliases(
|
|
existing: [String],
|
|
incoming: [String],
|
|
excludingTerm: String
|
|
) -> [String] {
|
|
let termLower = excludingTerm.lowercased()
|
|
return Array(
|
|
Set((existing + incoming).filter { $0.lowercased() != termLower })
|
|
).sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
|
}
|
|
}
|