Files
OSGKeyboard/OSGKeyboardShared/Services/AIHintStore.swift
Rocky e5a83843db Expand onboarding and adaptive keyboard intelligence
Add resilient usage analytics, OOBE gateway flows, clipboard semantic ranking, purchase recovery, style learning, and managed current-information search.
2026-08-22 16:33:18 +08:00

169 lines
6.0 KiB
Swift

// AIHintStore.swift
// OSGKeyboard · Shared
//
// Reads/writes host-ready hint packs from App Group. Keyboard only reads.
import Foundation
public enum AIHintStore: Sendable {
public static let refreshInterval: TimeInterval = 12 * 60 * 60
/// Without a feed `expiresAt`, a pack still stops being served once it is
/// this old — stale hot topics are worse than the evergreen local catalog.
public static let maximumPackAge: TimeInterval = 48 * 60 * 60
public static func loadReadyPack(
locale: String,
defaults: UserDefaults? = AppGroup.defaultsIfAvailable
) -> AIHintPack? {
guard let defaults,
let data = defaults.data(forKey: AIHintAppGroupKeys.readyPackKey(locale: locale))
else { return nil }
return try? JSONDecoder().decode(AIHintPack.self, from: data)
}
public static func saveReadyPack(
_ pack: AIHintPack,
defaults: UserDefaults? = AppGroup.defaultsIfAvailable
) {
guard let defaults else { return }
var copy = pack
copy.refreshedAt = copy.refreshedAt ?? Date()
guard let data = try? JSONEncoder().encode(copy) else { return }
defaults.set(data, forKey: AIHintAppGroupKeys.readyPackKey(locale: pack.locale))
defaults.set(
(copy.refreshedAt ?? Date()).timeIntervalSince1970,
forKey: AIHintAppGroupKeys.lastSuccessKey(locale: pack.locale)
)
defaults.synchronize()
}
public static func loadManifest(
defaults: UserDefaults? = AppGroup.defaultsIfAvailable
) -> AIHintManifest? {
guard let data = defaults?.data(forKey: AIHintAppGroupKeys.manifest) else {
return nil
}
return try? JSONDecoder().decode(AIHintManifest.self, from: data)
}
public static func saveManifest(
_ manifest: AIHintManifest,
etag: String?,
defaults: UserDefaults? = AppGroup.defaultsIfAvailable
) {
guard let defaults,
let data = try? JSONEncoder().encode(manifest) else { return }
defaults.set(data, forKey: AIHintAppGroupKeys.manifest)
setManifestETag(etag, defaults: defaults)
defaults.synchronize()
}
public static func manifestETag(
defaults: UserDefaults? = AppGroup.defaultsIfAvailable
) -> String? {
defaults?.string(forKey: AIHintAppGroupKeys.manifestETag)
}
public static func setManifestETag(
_ etag: String?,
defaults: UserDefaults? = AppGroup.defaultsIfAvailable
) {
guard let defaults else { return }
if let etag, !etag.isEmpty {
defaults.set(etag, forKey: AIHintAppGroupKeys.manifestETag)
} else {
defaults.removeObject(forKey: AIHintAppGroupKeys.manifestETag)
}
}
public static func packETag(
locale: String,
defaults: UserDefaults? = AppGroup.defaultsIfAvailable
) -> String? {
defaults?.string(forKey: AIHintAppGroupKeys.packETagKey(locale: locale))
}
public static func setPackETag(
_ etag: String?,
locale: String,
defaults: UserDefaults? = AppGroup.defaultsIfAvailable
) {
guard let defaults else { return }
let key = AIHintAppGroupKeys.packETagKey(locale: locale)
if let etag, !etag.isEmpty {
defaults.set(etag, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
public static func lastSuccessAt(
locale: String,
defaults: UserDefaults? = AppGroup.defaultsIfAvailable
) -> Date? {
let key = AIHintAppGroupKeys.lastSuccessKey(locale: locale)
guard let defaults, defaults.object(forKey: key) != nil else { return nil }
return Date(timeIntervalSince1970: defaults.double(forKey: key))
}
public static func markAttempt(
defaults: UserDefaults? = AppGroup.defaultsIfAvailable
) {
defaults?.set(Date().timeIntervalSince1970, forKey: AIHintAppGroupKeys.lastAttemptAt)
}
/// One stale locale is enough to schedule a refresh pass.
public static func shouldRefresh(
now: Date = Date(),
defaults: UserDefaults? = AppGroup.defaultsIfAvailable
) -> Bool {
AIHintFeedEndpoints.supportedLocales.contains { locale in
shouldRefresh(locale: locale, now: now, defaults: defaults)
}
}
public static func shouldRefresh(
locale: String,
now: Date = Date(),
defaults: UserDefaults? = AppGroup.defaultsIfAvailable
) -> Bool {
guard let last = lastSuccessAt(locale: locale, defaults: defaults) else { return true }
return now.timeIntervalSince(last) >= refreshInterval
}
/// Keyboard-facing pack: fresh ready remote/local merge, else built-in catalog.
public static func resolvedPack(
locale: String,
now: Date = Date(),
defaults: UserDefaults? = AppGroup.defaultsIfAvailable
) -> AIHintPack {
if let ready = loadReadyPack(locale: locale, defaults: defaults),
!ready.cards.isEmpty,
!isExpired(ready, now: now) {
return ready
}
return AIHintPack(
locale: locale,
cards: AIHintLocalCatalog.cards(locale: locale),
refreshedAt: nil
)
}
/// The feed's `expiresAt` is authoritative; `maximumPackAge` is the fallback.
static func isExpired(_ pack: AIHintPack, now: Date = Date()) -> Bool {
if let expiresAt = pack.expiresAt, let deadline = date(fromISO8601: expiresAt) {
return now > deadline
}
guard let refreshedAt = pack.refreshedAt else { return false }
return now.timeIntervalSince(refreshedAt) >= maximumPackAge
}
private static func date(fromISO8601 value: String) -> Date? {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatter.date(from: value) { return date }
formatter.formatOptions = [.withInternetDateTime]
return formatter.date(from: value)
}
}