feat: v2
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Кэш иконок монет: память → диск → CDN.
|
||||
///
|
||||
/// Диск — контейнер App Group, поэтому кэш общий для приложения и виджета:
|
||||
/// иконка скачивается один раз, а не при каждом обновлении таймлайна.
|
||||
actor IconStore {
|
||||
static let shared = IconStore()
|
||||
|
||||
/// Иконки монет практически не меняются — неделя жизни файла достаточна.
|
||||
private let maxAge: TimeInterval
|
||||
private let directory: URL?
|
||||
private var memory: [String: Data] = [:]
|
||||
|
||||
private static let logger = Logger(subsystem: AppGroup.id, category: "icon-store")
|
||||
|
||||
init(maxAge: TimeInterval = 7 * 24 * 60 * 60) {
|
||||
self.maxAge = maxAge
|
||||
self.directory = Self.makeDirectory()
|
||||
}
|
||||
|
||||
// MARK: - Публичное API
|
||||
|
||||
/// Иконка валюты; nil — если её нет ни в кэше, ни на CDN.
|
||||
func data(for currency: String) async -> Data? {
|
||||
if let data = memory[currency] {
|
||||
return data
|
||||
}
|
||||
|
||||
let file = fileURL(for: currency)
|
||||
if let file, let data = freshData(at: file) {
|
||||
memory[currency] = data
|
||||
return data
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try await RapiraAPI.shared.iconData(for: currency)
|
||||
memory[currency] = data
|
||||
if let file {
|
||||
try? data.write(to: file, options: .atomic)
|
||||
}
|
||||
return data
|
||||
} catch {
|
||||
Self.logger.notice(
|
||||
"Иконка \(currency, privacy: .public) недоступна: \(String(describing: error), privacy: .public)"
|
||||
)
|
||||
// Просроченный файл лучше пустого кружка.
|
||||
if let file, let stale = try? Data(contentsOf: file) {
|
||||
memory[currency] = stale
|
||||
return stale
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Пакетная загрузка для провайдера виджета.
|
||||
func icons(for currencies: some Sequence<String>) async -> [String: Data] {
|
||||
await withTaskGroup(of: (String, Data?).self) { group in
|
||||
for currency in Set(currencies) {
|
||||
group.addTask { (currency, await self.data(for: currency)) }
|
||||
}
|
||||
|
||||
var result: [String: Data] = [:]
|
||||
for await (currency, data) in group {
|
||||
if let data {
|
||||
result[currency] = data
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Диск
|
||||
|
||||
private func fileURL(for currency: String) -> URL? {
|
||||
directory?.appendingPathComponent("\(currency).svg")
|
||||
}
|
||||
|
||||
/// Данные файла, если он ещё не устарел.
|
||||
private func freshData(at url: URL) -> Data? {
|
||||
guard
|
||||
let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||
let modified = attributes[.modificationDate] as? Date,
|
||||
Date.now.timeIntervalSince(modified) < maxAge
|
||||
else { return nil }
|
||||
return try? Data(contentsOf: url)
|
||||
}
|
||||
|
||||
private static func makeDirectory() -> URL? {
|
||||
let base = AppGroup.cachesURL
|
||||
?? FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
|
||||
guard let base else { return nil }
|
||||
|
||||
let directory = base.appendingPathComponent("coin-icons", isDirectory: true)
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
return directory
|
||||
} catch {
|
||||
logger.error("Не удалось создать кэш иконок: \(String(describing: error), privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user