Files
2026-08-12 07:06:01 +07:00

105 lines
3.8 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}
}