// // DiskCache.swift // Tusker // // Created by Shadowfacts on 1/18/21. // Copyright © 2021 Shadowfacts. All rights reserved. // import Foundation import CryptoKit struct DiskCacheTransformer { let toData: (T) throws -> Data let fromData: (Data) throws -> T } class DiskCache { let fileManager: FileManager let path: String let defaultExpiry: CacheExpiry let transformer: DiskCacheTransformer private var fileStates = [String: FileState]() init(name: String, defaultExpiry: CacheExpiry, transformer: DiskCacheTransformer, fileManager: FileManager = .default) throws { self.defaultExpiry = defaultExpiry self.transformer = transformer self.fileManager = fileManager let cacheDir = try fileManager.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true) self.path = cacheDir.appendingPathComponent(name, isDirectory: true).path try createDirectory() } private func createDirectory() throws { if !fileManager.fileExists(atPath: path) { try fileManager.createDirectory(atPath: path, withIntermediateDirectories: true, attributes: nil) } } private func makeFileName(for key: String) -> String { let ext = (key as NSString).pathExtension let digest = Insecure.MD5.hash(data: key.data(using: .utf8)!) let hash = digest.map { String($0, radix: 16) }.joined() if ext.isEmpty { return hash } else { return "\(hash).\(ext)" } } private func makeFilePath(for key: String) -> String { return (path as NSString).appendingPathComponent(makeFileName(for: key)) } private func fileState(forKey key: String) -> FileState { return fileStates[key] ?? .unknown } func setObject(_ object: T, forKey key: String) throws { let data = try transformer.toData(object) let path = makeFilePath(for: key) guard fileManager.createFile(atPath: path, contents: data, attributes: [.modificationDate: defaultExpiry.date]) else { throw Error.couldNotCreateFile } fileStates[key] = .exists } func removeObject(forKey key: String) throws { let path = makeFilePath(for: key) try fileManager.removeItem(atPath: path) fileStates[key] = .doesNotExist } func existsObject(forKey key: String) throws -> Bool { switch fileState(forKey: key) { case .exists: return true case .doesNotExist: return false case .unknown: let path = makeFilePath(for: key) guard fileManager.fileExists(atPath: path) else { return false } let attributes = try fileManager.attributesOfItem(atPath: path) guard let date = attributes[.modificationDate] as? Date else { throw Error.malformedFileAttributes } return date.timeIntervalSinceNow >= 0 } } func object(forKey key: String) throws -> T { let path = makeFilePath(for: key) let attributes = try fileManager.attributesOfItem(atPath: path) guard let date = attributes[.modificationDate] as? Date else { throw Error.malformedFileAttributes } guard date.timeIntervalSinceNow >= 0 else { try fileManager.removeItem(atPath: path) fileStates[key] = .doesNotExist throw Error.expired } let data = try Data(contentsOf: URL(fileURLWithPath: path, isDirectory: false)) let object = try transformer.fromData(data) return object } func removeAll() throws { try fileManager.removeItem(atPath: path) try createDirectory() fileStates.removeAll() } } extension DiskCache { enum Error: Swift.Error { case malformedFileAttributes case couldNotCreateFile case expired } } extension DiskCache { enum FileState { case exists, doesNotExist, unknown } } extension DiskCache where T == Data { convenience init(name: String, defaultExpiry: CacheExpiry) throws { try self.init(name: name, defaultExpiry: defaultExpiry, transformer: DiskCacheTransformer(toData: { $0 }, fromData: { $0 })) } }