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:
@@ -36,6 +36,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
public static let settingsICloudSyncEnabled = "config.settings.iCloudSyncEnabled"
|
||||
/// Wall-clock stamp of the last settings blob applied from iCloud KVS.
|
||||
public static let settingsCloudUpdatedAt = "config.settings.cloudUpdatedAt"
|
||||
/// Cached per-field settings merge payload (`SyncedAppSettingsV2`).
|
||||
public static let settingsCloudPayloadV2 = "config.settings.cloudPayload.v2"
|
||||
/// When true, the host app auto-returns to the source app after a cold-start handoff.
|
||||
public static let flowSkipAppSwitch = "config.flowSkipAppSwitch"
|
||||
/// Raw `FlowInactivityDuration` value; session expires after this idle window.
|
||||
@@ -104,8 +106,13 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
}
|
||||
|
||||
/// API key lives in the Keychain (cross-process, encrypted at rest).
|
||||
/// When settings iCloud sync is on, reads synchronizable Keychain items first.
|
||||
public var apiKey: String {
|
||||
Keychain.apiKey(for: providerId) ?? ""
|
||||
Self.resolveAPIKey(
|
||||
defaults: nil,
|
||||
providerId: providerId,
|
||||
preferICloudSync: settingsICloudSyncEnabled
|
||||
)
|
||||
}
|
||||
|
||||
public func makeClient() -> LLMClient {
|
||||
@@ -206,7 +213,11 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
}
|
||||
|
||||
// One-shot legacy migration: plaintext apiKey in UserDefaults → Keychain.
|
||||
_ = resolveAPIKey(defaults: defaults, providerId: config.providerId)
|
||||
_ = resolveAPIKey(
|
||||
defaults: defaults,
|
||||
providerId: config.providerId,
|
||||
preferICloudSync: config.settingsICloudSyncEnabled
|
||||
)
|
||||
|
||||
// Cloud no longer exposes off/transcribe; migrate legacy values.
|
||||
if config.engineMode == "cloud", config.modeId != "polish" {
|
||||
@@ -295,19 +306,23 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
}
|
||||
|
||||
/// Read the API key from the Keychain, falling back to a one-time migration from UserDefaults.
|
||||
static func resolveAPIKey(defaults: UserDefaults?, providerId: String) -> String {
|
||||
if let stored = Keychain.apiKey(for: providerId), !stored.isEmpty {
|
||||
static func resolveAPIKey(
|
||||
defaults: UserDefaults?,
|
||||
providerId: String,
|
||||
preferICloudSync: Bool = false
|
||||
) -> String {
|
||||
if let stored = Keychain.apiKey(for: providerId, preferICloudSync: preferICloudSync), !stored.isEmpty {
|
||||
return stored
|
||||
}
|
||||
if let legacyKeychain = Keychain.legacyAPIKey(), !legacyKeychain.isEmpty {
|
||||
try? Keychain.setAPIKey(legacyKeychain, for: providerId)
|
||||
try? Keychain.setAPIKey(legacyKeychain, for: providerId, useICloudSync: preferICloudSync)
|
||||
try? Keychain.deleteLegacyAPIKey()
|
||||
return legacyKeychain
|
||||
}
|
||||
if let defaults,
|
||||
let legacy = defaults.string(forKey: Keys.apiKeyLegacy),
|
||||
!legacy.isEmpty {
|
||||
try? Keychain.setAPIKey(legacy, for: providerId)
|
||||
try? Keychain.setAPIKey(legacy, for: providerId, useICloudSync: preferICloudSync)
|
||||
defaults.removeObject(forKey: Keys.apiKeyLegacy)
|
||||
return legacy
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// MicVoiceAvailability+Keyboard.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Derives keyboard mic availability from pipeline phase and host readiness.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum MicVoiceAvailabilityResolver {
|
||||
public static func resolve(
|
||||
phase: KeyboardState.Phase,
|
||||
micDisabled: Bool,
|
||||
hasFullAccess: Bool,
|
||||
appGroupAvailable: Bool,
|
||||
hostReady: Bool,
|
||||
isPreparingSession: Bool
|
||||
) -> MicVoiceAvailability {
|
||||
switch phase {
|
||||
case .recording:
|
||||
return .recording
|
||||
case .processing, .requestingPermissions:
|
||||
return .processing
|
||||
case .error, .denied:
|
||||
return .unavailable(.hostNotReady)
|
||||
case .idle:
|
||||
break
|
||||
}
|
||||
|
||||
if !appGroupAvailable {
|
||||
return .unavailable(.appGroupUnavailable)
|
||||
}
|
||||
if !hasFullAccess {
|
||||
return .unavailable(.noFullAccess)
|
||||
}
|
||||
if micDisabled {
|
||||
return .unavailable(.missingAPIKey)
|
||||
}
|
||||
if isPreparingSession {
|
||||
return .unavailable(.preparingSession)
|
||||
}
|
||||
if hostReady {
|
||||
return .ready
|
||||
}
|
||||
return .unavailable(.hostNotReady)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// MicVoiceAvailability.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Single source of truth for keyboard mic color, hint text, and tap behavior.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Whether the keyboard mic can start a Flow utterance right now.
|
||||
public enum MicVoiceAvailability: Equatable, Sendable {
|
||||
/// Green — tap records immediately without opening the host app.
|
||||
case ready
|
||||
/// Orange — voice input blocked; see `Reason` for hint copy.
|
||||
case unavailable(Reason)
|
||||
/// Red — user is actively recording.
|
||||
case recording
|
||||
/// White — waiting for ASR / cloud polish after stop.
|
||||
case processing
|
||||
|
||||
public enum Reason: Equatable, Sendable {
|
||||
case missingAPIKey
|
||||
case hostNotReady
|
||||
case noFullAccess
|
||||
case appGroupUnavailable
|
||||
/// User tapped mic; host app jump in progress, awaiting ready contract.
|
||||
case preparingSession
|
||||
}
|
||||
|
||||
public var isReady: Bool {
|
||||
if case .ready = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
public var isUnavailable: Bool {
|
||||
if case .unavailable = self { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -36,7 +36,11 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
didSet {
|
||||
guard oldValue != apiKey, !isSyncingProviderAPIKey else { return }
|
||||
do {
|
||||
try Keychain.setAPIKey(apiKey, for: providerId)
|
||||
try Keychain.setAPIKey(
|
||||
apiKey,
|
||||
for: providerId,
|
||||
useICloudSync: configuration.settingsICloudSyncEnabled
|
||||
)
|
||||
} catch {
|
||||
OSGLog.config.warning("Keychain write failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// ProviderLogo.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Maps a provider id to its asset-catalog logo name. Shared by the iOS
|
||||
// app and the macOS menu-bar app so both show identical brand marks.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum ProviderLogo {
|
||||
/// Asset name for the provider's logo, or `nil` when there is no bundled logo.
|
||||
public static func assetName(for providerId: String) -> String? {
|
||||
switch providerId {
|
||||
case "openai": return "openai"
|
||||
case "deepseek": return "deepseek"
|
||||
case "qwen": return "qwen"
|
||||
case "moonshot": return "moonshot"
|
||||
case "zhipu": return "zhipu"
|
||||
case "mimo": return "mimo"
|
||||
case "apple": return "apple"
|
||||
case "custom": return "custom"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// SpeechHistoryEntry.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// A single voice transcription in the cross-device history log.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct SpeechHistoryEntry: Codable, Identifiable, Equatable, Sendable {
|
||||
public let id: UUID
|
||||
public let text: String
|
||||
public let createdAt: Date
|
||||
/// iOS Flow engine mode; nil on macOS captures.
|
||||
public let engineMode: String?
|
||||
|
||||
public init(
|
||||
id: UUID = UUID(),
|
||||
text: String,
|
||||
createdAt: Date = Date(),
|
||||
engineMode: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.text = text
|
||||
self.createdAt = createdAt
|
||||
self.engineMode = engineMode
|
||||
}
|
||||
|
||||
/// First-line preview for compact list rows (macOS history sidebar).
|
||||
public var previewTitle: String {
|
||||
let firstLine = text.split(separator: "\n").first.map(String.init) ?? text
|
||||
return firstLine.count > 36 ? String(firstLine.prefix(36)) + "…" : firstLine
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
// SyncedAppSettings.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// User-facing app settings mirrored through iCloud KVS. Excludes
|
||||
// device-local state (onboarding progress, detected app context,
|
||||
// personal dictionary blob, and API keys in Keychain).
|
||||
// Legacy v1 settings blob (read-only migration input). New sync uses
|
||||
// `SyncedAppSettingsV2`. API keys never belong in KVS payloads.
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -23,6 +22,8 @@ public struct SyncedAppSettings: Codable, Sendable, Equatable {
|
||||
public var polishIntensity: PolishIntensity
|
||||
public var flowSkipAppSwitch: Bool
|
||||
public var flowInactivityDuration: FlowInactivityDuration
|
||||
/// Deprecated — decoded for backward compatibility only; never applied.
|
||||
public var providerAPIKeys: [String: String]
|
||||
|
||||
public init(
|
||||
updatedAt: Date = Date(),
|
||||
@@ -39,7 +40,8 @@ public struct SyncedAppSettings: Codable, Sendable, Equatable {
|
||||
cursorDragNavigationEnabled: Bool,
|
||||
polishIntensity: PolishIntensity,
|
||||
flowSkipAppSwitch: Bool,
|
||||
flowInactivityDuration: FlowInactivityDuration
|
||||
flowInactivityDuration: FlowInactivityDuration,
|
||||
providerAPIKeys: [String: String] = [:]
|
||||
) {
|
||||
self.updatedAt = updatedAt
|
||||
self.providerId = providerId
|
||||
@@ -56,11 +58,51 @@ public struct SyncedAppSettings: Codable, Sendable, Equatable {
|
||||
self.polishIntensity = polishIntensity
|
||||
self.flowSkipAppSwitch = flowSkipAppSwitch
|
||||
self.flowInactivityDuration = flowInactivityDuration
|
||||
self.providerAPIKeys = providerAPIKeys
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
|
||||
providerId = try container.decode(String.self, forKey: .providerId)
|
||||
baseURL = try container.decode(String.self, forKey: .baseURL)
|
||||
model = try container.decode(String.self, forKey: .model)
|
||||
modeId = try container.decode(String.self, forKey: .modeId)
|
||||
localeId = try container.decode(String.self, forKey: .localeId)
|
||||
engineMode = try container.decode(String.self, forKey: .engineMode)
|
||||
hasAcknowledgedCloudSharing = try container.decode(Bool.self, forKey: .hasAcknowledgedCloudSharing)
|
||||
uiLanguage = try container.decode(AppUILanguage.self, forKey: .uiLanguage)
|
||||
translationTargetLocaleId = try container.decode(String.self, forKey: .translationTargetLocaleId)
|
||||
handednessPreference = try container.decode(HandednessPreference.self, forKey: .handednessPreference)
|
||||
cursorDragNavigationEnabled = try container.decode(Bool.self, forKey: .cursorDragNavigationEnabled)
|
||||
polishIntensity = try container.decode(PolishIntensity.self, forKey: .polishIntensity)
|
||||
flowSkipAppSwitch = try container.decode(Bool.self, forKey: .flowSkipAppSwitch)
|
||||
flowInactivityDuration = try container.decode(FlowInactivityDuration.self, forKey: .flowInactivityDuration)
|
||||
providerAPIKeys = try container.decodeIfPresent([String: String].self, forKey: .providerAPIKeys) ?? [:]
|
||||
}
|
||||
|
||||
/// Apply legacy scalar fields only — never touches Keychain.
|
||||
func applyingScalars(to configuration: inout AppGroupConfiguration) {
|
||||
configuration.providerId = providerId
|
||||
configuration.baseURL = baseURL
|
||||
configuration.model = model
|
||||
configuration.modeId = modeId
|
||||
configuration.localeId = localeId
|
||||
configuration.engineMode = engineMode
|
||||
configuration.hasAcknowledgedCloudSharing = hasAcknowledgedCloudSharing
|
||||
configuration.uiLanguage = uiLanguage
|
||||
configuration.translationTargetLocaleId = translationTargetLocaleId
|
||||
configuration.handednessPreference = handednessPreference
|
||||
configuration.cursorDragNavigationEnabled = cursorDragNavigationEnabled
|
||||
configuration.polishIntensity = polishIntensity
|
||||
configuration.flowSkipAppSwitch = flowSkipAppSwitch
|
||||
configuration.flowInactivityDuration = flowInactivityDuration
|
||||
}
|
||||
}
|
||||
|
||||
public extension SyncedAppSettings {
|
||||
/// Build a cloud payload from the current App Group configuration.
|
||||
static let legacyKVSKey = "appSettings.v1"
|
||||
|
||||
static func from(configuration: AppGroupConfiguration, updatedAt: Date = Date()) -> SyncedAppSettings {
|
||||
SyncedAppSettings(
|
||||
updatedAt: updatedAt,
|
||||
@@ -80,28 +122,4 @@ public extension SyncedAppSettings {
|
||||
flowInactivityDuration: configuration.flowInactivityDuration
|
||||
)
|
||||
}
|
||||
|
||||
/// Apply syncable fields onto a configuration, preserving device-local
|
||||
/// fields such as onboarding progress and the personal dictionary.
|
||||
func applying(to configuration: inout AppGroupConfiguration) {
|
||||
configuration.providerId = providerId
|
||||
configuration.baseURL = baseURL
|
||||
configuration.model = model
|
||||
configuration.modeId = modeId
|
||||
configuration.localeId = localeId
|
||||
configuration.engineMode = engineMode
|
||||
configuration.hasAcknowledgedCloudSharing = hasAcknowledgedCloudSharing
|
||||
configuration.uiLanguage = uiLanguage
|
||||
configuration.translationTargetLocaleId = translationTargetLocaleId
|
||||
configuration.handednessPreference = handednessPreference
|
||||
configuration.cursorDragNavigationEnabled = cursorDragNavigationEnabled
|
||||
configuration.polishIntensity = polishIntensity
|
||||
configuration.flowSkipAppSwitch = flowSkipAppSwitch
|
||||
configuration.flowInactivityDuration = flowInactivityDuration
|
||||
}
|
||||
|
||||
/// Last-write-wins merge for whole settings blobs.
|
||||
static func merge(local: SyncedAppSettings, remote: SyncedAppSettings) -> SyncedAppSettings {
|
||||
remote.updatedAt >= local.updatedAt ? remote : local
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
// SyncedAppSettingsV2.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Versioned settings payload with per-field merge metadata. API keys are
|
||||
// intentionally excluded — they sync through iCloud Keychain.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
public static let schemaVersion = 2
|
||||
public static let kvsKey = "appSettings.v2"
|
||||
|
||||
public var schemaVersion: Int
|
||||
public var providerId: SyncedField<String>
|
||||
public var baseURL: SyncedField<String>
|
||||
public var model: SyncedField<String>
|
||||
public var modeId: SyncedField<String>
|
||||
public var localeId: SyncedField<String>
|
||||
public var engineMode: SyncedField<String>
|
||||
public var hasAcknowledgedCloudSharing: SyncedField<Bool>
|
||||
public var uiLanguage: SyncedField<AppUILanguage>
|
||||
public var translationTargetLocaleId: SyncedField<String>
|
||||
public var handednessPreference: SyncedField<HandednessPreference>
|
||||
public var cursorDragNavigationEnabled: SyncedField<Bool>
|
||||
public var polishIntensity: SyncedField<PolishIntensity>
|
||||
public var flowSkipAppSwitch: SyncedField<Bool>
|
||||
public var flowInactivityDuration: SyncedField<FlowInactivityDuration>
|
||||
|
||||
public init(
|
||||
schemaVersion: Int = Self.schemaVersion,
|
||||
providerId: SyncedField<String>,
|
||||
baseURL: SyncedField<String>,
|
||||
model: SyncedField<String>,
|
||||
modeId: SyncedField<String>,
|
||||
localeId: SyncedField<String>,
|
||||
engineMode: SyncedField<String>,
|
||||
hasAcknowledgedCloudSharing: SyncedField<Bool>,
|
||||
uiLanguage: SyncedField<AppUILanguage>,
|
||||
translationTargetLocaleId: SyncedField<String>,
|
||||
handednessPreference: SyncedField<HandednessPreference>,
|
||||
cursorDragNavigationEnabled: SyncedField<Bool>,
|
||||
polishIntensity: SyncedField<PolishIntensity>,
|
||||
flowSkipAppSwitch: SyncedField<Bool>,
|
||||
flowInactivityDuration: SyncedField<FlowInactivityDuration>
|
||||
) {
|
||||
self.schemaVersion = schemaVersion
|
||||
self.providerId = providerId
|
||||
self.baseURL = baseURL
|
||||
self.model = model
|
||||
self.modeId = modeId
|
||||
self.localeId = localeId
|
||||
self.engineMode = engineMode
|
||||
self.hasAcknowledgedCloudSharing = hasAcknowledgedCloudSharing
|
||||
self.uiLanguage = uiLanguage
|
||||
self.translationTargetLocaleId = translationTargetLocaleId
|
||||
self.handednessPreference = handednessPreference
|
||||
self.cursorDragNavigationEnabled = cursorDragNavigationEnabled
|
||||
self.polishIntensity = polishIntensity
|
||||
self.flowSkipAppSwitch = flowSkipAppSwitch
|
||||
self.flowInactivityDuration = flowInactivityDuration
|
||||
}
|
||||
|
||||
/// Monotonic stamp used for `settingsCloudUpdatedAt` bookkeeping.
|
||||
public var latestUpdatedAt: Date {
|
||||
[
|
||||
providerId.updatedAt,
|
||||
baseURL.updatedAt,
|
||||
model.updatedAt,
|
||||
modeId.updatedAt,
|
||||
localeId.updatedAt,
|
||||
engineMode.updatedAt,
|
||||
hasAcknowledgedCloudSharing.updatedAt,
|
||||
uiLanguage.updatedAt,
|
||||
translationTargetLocaleId.updatedAt,
|
||||
handednessPreference.updatedAt,
|
||||
cursorDragNavigationEnabled.updatedAt,
|
||||
polishIntensity.updatedAt,
|
||||
flowSkipAppSwitch.updatedAt,
|
||||
flowInactivityDuration.updatedAt,
|
||||
].max() ?? .distantPast
|
||||
}
|
||||
}
|
||||
|
||||
public extension SyncedAppSettingsV2 {
|
||||
static func from(configuration: AppGroupConfiguration, deviceID: String) -> SyncedAppSettingsV2 {
|
||||
seeded(from: configuration, deviceID: deviceID, updatedAt: Date())
|
||||
}
|
||||
|
||||
/// Build a payload from configuration using one shared timestamp (for merge bookkeeping).
|
||||
static func seeded(
|
||||
from configuration: AppGroupConfiguration,
|
||||
deviceID: String,
|
||||
updatedAt: Date
|
||||
) -> SyncedAppSettingsV2 {
|
||||
func field<T>(_ value: T) -> SyncedField<T> {
|
||||
SyncedField(value: value, updatedAt: updatedAt, deviceID: deviceID)
|
||||
}
|
||||
return SyncedAppSettingsV2(
|
||||
providerId: field(configuration.providerId),
|
||||
baseURL: field(configuration.baseURL),
|
||||
model: field(configuration.model),
|
||||
modeId: field(configuration.modeId),
|
||||
localeId: field(configuration.localeId),
|
||||
engineMode: field(configuration.engineMode),
|
||||
hasAcknowledgedCloudSharing: field(configuration.hasAcknowledgedCloudSharing),
|
||||
uiLanguage: field(configuration.uiLanguage),
|
||||
translationTargetLocaleId: field(configuration.translationTargetLocaleId),
|
||||
handednessPreference: field(configuration.handednessPreference),
|
||||
cursorDragNavigationEnabled: field(configuration.cursorDragNavigationEnabled),
|
||||
polishIntensity: field(configuration.polishIntensity),
|
||||
flowSkipAppSwitch: field(configuration.flowSkipAppSwitch),
|
||||
flowInactivityDuration: field(configuration.flowInactivityDuration)
|
||||
)
|
||||
}
|
||||
|
||||
/// Upgrade a legacy v1 blob into per-field metadata on this device.
|
||||
static func migrated(from legacy: SyncedAppSettings, deviceID: String) -> SyncedAppSettingsV2 {
|
||||
let stamp = legacy.updatedAt
|
||||
func field<T>(_ value: T) -> SyncedField<T> {
|
||||
SyncedField(value: value, updatedAt: stamp, deviceID: deviceID)
|
||||
}
|
||||
return SyncedAppSettingsV2(
|
||||
providerId: field(legacy.providerId),
|
||||
baseURL: field(legacy.baseURL),
|
||||
model: field(legacy.model),
|
||||
modeId: field(legacy.modeId),
|
||||
localeId: field(legacy.localeId),
|
||||
engineMode: field(legacy.engineMode),
|
||||
hasAcknowledgedCloudSharing: field(legacy.hasAcknowledgedCloudSharing),
|
||||
uiLanguage: field(legacy.uiLanguage),
|
||||
translationTargetLocaleId: field(legacy.translationTargetLocaleId),
|
||||
handednessPreference: field(legacy.handednessPreference),
|
||||
cursorDragNavigationEnabled: field(legacy.cursorDragNavigationEnabled),
|
||||
polishIntensity: field(legacy.polishIntensity),
|
||||
flowSkipAppSwitch: field(legacy.flowSkipAppSwitch),
|
||||
flowInactivityDuration: field(legacy.flowInactivityDuration)
|
||||
)
|
||||
}
|
||||
|
||||
static func merge(local: SyncedAppSettingsV2, remote: SyncedAppSettingsV2) -> SyncedAppSettingsV2 {
|
||||
SyncedAppSettingsV2(
|
||||
providerId: .merge(local: local.providerId, remote: remote.providerId),
|
||||
baseURL: .merge(local: local.baseURL, remote: remote.baseURL),
|
||||
model: .merge(local: local.model, remote: remote.model),
|
||||
modeId: .merge(local: local.modeId, remote: remote.modeId),
|
||||
localeId: .merge(local: local.localeId, remote: remote.localeId),
|
||||
engineMode: .merge(local: local.engineMode, remote: remote.engineMode),
|
||||
hasAcknowledgedCloudSharing: .merge(
|
||||
local: local.hasAcknowledgedCloudSharing,
|
||||
remote: remote.hasAcknowledgedCloudSharing
|
||||
),
|
||||
uiLanguage: .merge(local: local.uiLanguage, remote: remote.uiLanguage),
|
||||
translationTargetLocaleId: .merge(
|
||||
local: local.translationTargetLocaleId,
|
||||
remote: remote.translationTargetLocaleId
|
||||
),
|
||||
handednessPreference: .merge(local: local.handednessPreference, remote: remote.handednessPreference),
|
||||
cursorDragNavigationEnabled: .merge(
|
||||
local: local.cursorDragNavigationEnabled,
|
||||
remote: remote.cursorDragNavigationEnabled
|
||||
),
|
||||
polishIntensity: .merge(local: local.polishIntensity, remote: remote.polishIntensity),
|
||||
flowSkipAppSwitch: .merge(local: local.flowSkipAppSwitch, remote: remote.flowSkipAppSwitch),
|
||||
flowInactivityDuration: .merge(
|
||||
local: local.flowInactivityDuration,
|
||||
remote: remote.flowInactivityDuration
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func applying(to configuration: inout AppGroupConfiguration) {
|
||||
configuration.providerId = providerId.value
|
||||
configuration.baseURL = baseURL.value
|
||||
configuration.model = model.value
|
||||
configuration.modeId = modeId.value
|
||||
configuration.localeId = localeId.value
|
||||
configuration.engineMode = engineMode.value
|
||||
configuration.hasAcknowledgedCloudSharing = hasAcknowledgedCloudSharing.value
|
||||
configuration.uiLanguage = uiLanguage.value
|
||||
configuration.translationTargetLocaleId = translationTargetLocaleId.value
|
||||
configuration.handednessPreference = handednessPreference.value
|
||||
configuration.cursorDragNavigationEnabled = cursorDragNavigationEnabled.value
|
||||
configuration.polishIntensity = polishIntensity.value
|
||||
configuration.flowSkipAppSwitch = flowSkipAppSwitch.value
|
||||
configuration.flowInactivityDuration = flowInactivityDuration.value
|
||||
}
|
||||
|
||||
/// Stamp fields whose values differ from `configuration` with this device id.
|
||||
func patchLocalChanges(from configuration: AppGroupConfiguration, deviceID: String) -> SyncedAppSettingsV2 {
|
||||
var copy = self
|
||||
func patch<T: Equatable>(_ field: inout SyncedField<T>, value: T) {
|
||||
guard field.value != value else { return }
|
||||
field = .make(value: value, deviceID: deviceID)
|
||||
}
|
||||
patch(©.providerId, value: configuration.providerId)
|
||||
patch(©.baseURL, value: configuration.baseURL)
|
||||
patch(©.model, value: configuration.model)
|
||||
patch(©.modeId, value: configuration.modeId)
|
||||
patch(©.localeId, value: configuration.localeId)
|
||||
patch(©.engineMode, value: configuration.engineMode)
|
||||
patch(©.hasAcknowledgedCloudSharing, value: configuration.hasAcknowledgedCloudSharing)
|
||||
patch(©.uiLanguage, value: configuration.uiLanguage)
|
||||
patch(©.translationTargetLocaleId, value: configuration.translationTargetLocaleId)
|
||||
patch(©.handednessPreference, value: configuration.handednessPreference)
|
||||
patch(©.cursorDragNavigationEnabled, value: configuration.cursorDragNavigationEnabled)
|
||||
patch(©.polishIntensity, value: configuration.polishIntensity)
|
||||
patch(©.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
|
||||
patch(©.flowInactivityDuration, value: configuration.flowInactivityDuration)
|
||||
return copy
|
||||
}
|
||||
|
||||
/// Refresh only fields owned by `deviceID` from the current local configuration.
|
||||
func refreshedLocalFields(from configuration: AppGroupConfiguration, deviceID: String) -> SyncedAppSettingsV2 {
|
||||
var copy = self
|
||||
let now = Date()
|
||||
func touch<T>(_ field: inout SyncedField<T>, value: T) {
|
||||
guard field.deviceID == deviceID else { return }
|
||||
field.value = value
|
||||
field.updatedAt = now
|
||||
}
|
||||
touch(©.providerId, value: configuration.providerId)
|
||||
touch(©.baseURL, value: configuration.baseURL)
|
||||
touch(©.model, value: configuration.model)
|
||||
touch(©.modeId, value: configuration.modeId)
|
||||
touch(©.localeId, value: configuration.localeId)
|
||||
touch(©.engineMode, value: configuration.engineMode)
|
||||
touch(©.hasAcknowledgedCloudSharing, value: configuration.hasAcknowledgedCloudSharing)
|
||||
touch(©.uiLanguage, value: configuration.uiLanguage)
|
||||
touch(©.translationTargetLocaleId, value: configuration.translationTargetLocaleId)
|
||||
touch(©.handednessPreference, value: configuration.handednessPreference)
|
||||
touch(©.cursorDragNavigationEnabled, value: configuration.cursorDragNavigationEnabled)
|
||||
touch(©.polishIntensity, value: configuration.polishIntensity)
|
||||
touch(©.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
|
||||
touch(©.flowInactivityDuration, value: configuration.flowInactivityDuration)
|
||||
return copy
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// SyncedField.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Per-field metadata for conflict-free settings merge across devices.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct SyncedField<T: Codable & Equatable & Sendable>: Codable, Equatable, Sendable {
|
||||
public var value: T
|
||||
public var updatedAt: Date
|
||||
public var deviceID: String
|
||||
|
||||
public init(value: T, updatedAt: Date = Date(), deviceID: String) {
|
||||
self.value = value
|
||||
self.updatedAt = updatedAt
|
||||
self.deviceID = deviceID
|
||||
}
|
||||
|
||||
/// Pick the field with the newer `updatedAt`; ties break lexicographically on `deviceID`.
|
||||
public static func merge(local: SyncedField<T>, remote: SyncedField<T>) -> SyncedField<T> {
|
||||
if remote.updatedAt > local.updatedAt { return remote }
|
||||
if local.updatedAt > remote.updatedAt { return local }
|
||||
return remote.deviceID >= local.deviceID ? remote : local
|
||||
}
|
||||
|
||||
public static func make(value: T, deviceID: String) -> SyncedField<T> {
|
||||
SyncedField(value: value, updatedAt: Date(), deviceID: deviceID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// SyncedSpeechHistory.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// iCloud KVS payload for speech history. Tombstones and `clearedAt`
|
||||
// propagate single-entry deletes and "clear all" across devices.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
|
||||
public static let schemaVersion = 2
|
||||
public static let kvsKey = "speechHistory.v2"
|
||||
public static let legacyKVSKey = "speechHistory.v1"
|
||||
public static let maxEntries = 300
|
||||
/// Tombstones older than this window may be pruned during merge.
|
||||
public static let tombstoneRetention: TimeInterval = 90 * 24 * 60 * 60
|
||||
|
||||
public var schemaVersion: Int
|
||||
public var updatedAt: Date
|
||||
public var entries: [SpeechHistoryEntry]
|
||||
/// Entry IDs deleted on any device, with deletion timestamps.
|
||||
public var deletedEntryIDs: [UUID: Date]
|
||||
/// When set, entries created at or before this instant are excluded.
|
||||
public var clearedAt: Date?
|
||||
|
||||
public init(
|
||||
schemaVersion: Int = Self.schemaVersion,
|
||||
updatedAt: Date = Date(),
|
||||
entries: [SpeechHistoryEntry] = [],
|
||||
deletedEntryIDs: [UUID: Date] = [:],
|
||||
clearedAt: Date? = nil
|
||||
) {
|
||||
self.schemaVersion = schemaVersion
|
||||
self.updatedAt = updatedAt
|
||||
self.entries = entries
|
||||
self.deletedEntryIDs = deletedEntryIDs
|
||||
self.clearedAt = clearedAt
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
|
||||
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
|
||||
entries = try container.decodeIfPresent([SpeechHistoryEntry].self, forKey: .entries) ?? []
|
||||
if let map = try container.decodeIfPresent([UUID: Date].self, forKey: .deletedEntryIDs) {
|
||||
deletedEntryIDs = map
|
||||
} else if let legacyIDs = try container.decodeIfPresent([UUID].self, forKey: .deletedEntryIDs) {
|
||||
let stamp = Date()
|
||||
deletedEntryIDs = Dictionary(uniqueKeysWithValues: legacyIDs.map { ($0, stamp) })
|
||||
} else {
|
||||
deletedEntryIDs = [:]
|
||||
}
|
||||
clearedAt = try container.decodeIfPresent(Date.self, forKey: .clearedAt)
|
||||
}
|
||||
|
||||
public static let empty = SyncedSpeechHistory(updatedAt: .distantPast)
|
||||
|
||||
/// Union entries by id (newer `createdAt` wins), apply tombstones and clear.
|
||||
public static func merge(local: SyncedSpeechHistory, remote: SyncedSpeechHistory) -> SyncedSpeechHistory {
|
||||
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 byID: [UUID: SpeechHistoryEntry] = [:]
|
||||
for entry in local.entries + remote.entries {
|
||||
if deletedIDs[entry.id] != nil { continue }
|
||||
if let clearedAt, entry.createdAt <= clearedAt { continue }
|
||||
if let existing = byID[entry.id] {
|
||||
byID[entry.id] = entry.createdAt >= existing.createdAt ? entry : existing
|
||||
} else {
|
||||
byID[entry.id] = entry
|
||||
}
|
||||
}
|
||||
|
||||
var entries = Array(byID.values).sorted { $0.createdAt > $1.createdAt }
|
||||
if entries.count > maxEntries {
|
||||
entries = Array(entries.prefix(maxEntries))
|
||||
}
|
||||
|
||||
return SyncedSpeechHistory(
|
||||
updatedAt: max(local.updatedAt, remote.updatedAt),
|
||||
entries: entries,
|
||||
deletedEntryIDs: deletedIDs,
|
||||
clearedAt: clearedAt
|
||||
)
|
||||
}
|
||||
|
||||
/// Trim to the newest `maxEntries` rows (call after local-only appends).
|
||||
public mutating func trimEntries() {
|
||||
guard entries.count > Self.maxEntries else { return }
|
||||
entries = Array(entries.sorted { $0.createdAt > $1.createdAt }.prefix(Self.maxEntries))
|
||||
updatedAt = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SyncedSpeechHistory {
|
||||
mutating func recordClearAll(at date: Date = Date()) {
|
||||
entries.removeAll()
|
||||
clearedAt = date
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// UsageStatistics.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Cumulative dictation metrics shown on the home / dashboard stats cards.
|
||||
// Mirrored through iCloud KVS when settings sync is enabled.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct UsageStatistics: 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 let zero = UsageStatistics(updatedAt: .distantPast)
|
||||
|
||||
/// Combine lifetime totals from two devices. After merge, each device
|
||||
/// continues accumulating locally so `max` converges to the union.
|
||||
public static func merge(local: UsageStatistics, remote: UsageStatistics) -> UsageStatistics {
|
||||
UsageStatistics(
|
||||
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 enum UsageStatisticsStorage {
|
||||
public static let storageKey = "usageStatistics.v1"
|
||||
/// Legacy macOS dashboard counter (word split); migrated on first load.
|
||||
public static let legacyMacTotalWordsKey = "mac.totalWords"
|
||||
/// Pre–App Group iOS storage in `UserDefaults.standard`.
|
||||
public static let legacyStandardDefaultsKey = "usageStatistics.v1"
|
||||
|
||||
public static func load(from defaults: UserDefaults) -> UsageStatistics {
|
||||
if let data = defaults.data(forKey: storageKey),
|
||||
let stats = try? JSONDecoder().decode(UsageStatistics.self, from: data) {
|
||||
return stats
|
||||
}
|
||||
return .zero
|
||||
}
|
||||
|
||||
public static func save(_ stats: UsageStatistics, to defaults: UserDefaults) {
|
||||
guard let data = try? JSONEncoder().encode(stats) else { return }
|
||||
defaults.set(data, forKey: storageKey)
|
||||
}
|
||||
|
||||
/// One-time imports from older per-platform keys.
|
||||
public static func migrateLegacyIfNeeded(into defaults: UserDefaults) -> UsageStatistics {
|
||||
var stats = load(from: defaults)
|
||||
guard stats == .zero else { return stats }
|
||||
|
||||
let legacyWords = defaults.integer(forKey: legacyMacTotalWordsKey)
|
||||
if legacyWords > 0 {
|
||||
stats.dictationCharacterCount = legacyWords
|
||||
stats.updatedAt = Date()
|
||||
save(stats, to: defaults)
|
||||
return stats
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
if let data = UserDefaults.standard.data(forKey: legacyStandardDefaultsKey),
|
||||
let legacy = try? JSONDecoder().decode(UsageStatistics.self, from: data),
|
||||
legacy != .zero {
|
||||
stats = legacy
|
||||
stats.updatedAt = Date()
|
||||
save(stats, to: defaults)
|
||||
}
|
||||
#endif
|
||||
|
||||
return stats
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user