105 lines
3.8 KiB
Swift
105 lines
3.8 KiB
Swift
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
|
||
}
|
||
}
|
||
}
|