This commit is contained in:
2026-08-12 07:06:01 +07:00
parent 1025658518
commit 9bc96dbc2d
39 changed files with 1682 additions and 658 deletions
+104
View File
@@ -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
}
}
}