3de665d254
Add the AI keyboard tab, Agent settings, and user-owned LLM key path for 1.7.0, including streaming answers and web-search transports without the built-in DeepSeek fallback.
124 lines
4.6 KiB
Swift
124 lines
4.6 KiB
Swift
// 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 var aiCharacterCount: Int
|
||
|
||
public init(
|
||
updatedAt: Date = Date(),
|
||
dictationDurationSeconds: TimeInterval = 0,
|
||
dictationCharacterCount: Int = 0,
|
||
translationCharacterCount: Int = 0,
|
||
aiCharacterCount: Int = 0
|
||
) {
|
||
self.updatedAt = updatedAt
|
||
self.dictationDurationSeconds = dictationDurationSeconds
|
||
self.dictationCharacterCount = dictationCharacterCount
|
||
self.translationCharacterCount = translationCharacterCount
|
||
self.aiCharacterCount = aiCharacterCount
|
||
}
|
||
|
||
public static let zero = UsageStatistics(updatedAt: .distantPast)
|
||
|
||
public var totalInputCharacterCount: Int {
|
||
dictationCharacterCount + translationCharacterCount + aiCharacterCount
|
||
}
|
||
|
||
/// 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),
|
||
aiCharacterCount: max(local.aiCharacterCount, remote.aiCharacterCount)
|
||
)
|
||
}
|
||
|
||
private enum CodingKeys: String, CodingKey {
|
||
case updatedAt
|
||
case dictationDurationSeconds
|
||
case dictationCharacterCount
|
||
case translationCharacterCount
|
||
case aiCharacterCount
|
||
}
|
||
|
||
public init(from decoder: Decoder) throws {
|
||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
|
||
dictationDurationSeconds = try container.decodeIfPresent(
|
||
TimeInterval.self,
|
||
forKey: .dictationDurationSeconds
|
||
) ?? 0
|
||
dictationCharacterCount = try container.decodeIfPresent(
|
||
Int.self,
|
||
forKey: .dictationCharacterCount
|
||
) ?? 0
|
||
translationCharacterCount = try container.decodeIfPresent(
|
||
Int.self,
|
||
forKey: .translationCharacterCount
|
||
) ?? 0
|
||
aiCharacterCount = try container.decodeIfPresent(
|
||
Int.self,
|
||
forKey: .aiCharacterCount
|
||
) ?? 0
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|