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) 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 } } }