// // InfrastructureFactory.swift // Passepartout // // Created by Davide De Rosa on 9/2/18. // Copyright (c) 2019 Davide De Rosa. All rights reserved. // // https://github.com/passepartoutvpn // // This file is part of Passepartout. // // Passepartout is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Passepartout is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Passepartout. If not, see . // import Foundation import SwiftyBeaver private let log = SwiftyBeaver.self class InfrastructureFactory { private static func embedded(withName name: Infrastructure.Name) -> Infrastructure { guard let url = name.bundleURL else { fatalError("Cannot find JSON for infrastructure '\(name)'") } do { return try Infrastructure.loaded(from: url) } catch let e { fatalError("Cannot parse JSON for infrastructure '\(name)': \(e)") } } private static func isNewer(cachedEntry: URL, thanBundleWithName name: Infrastructure.Name) -> Bool { guard let cacheDate = FileManager.default.modificationDate(of: cachedEntry.path) else { return false } guard let bundleURL = name.bundleURL else { return true } guard let bundleDate = FileManager.default.modificationDate(of: bundleURL.path) else { return true } return cacheDate > bundleDate } static let shared = InfrastructureFactory() let allNames: [Infrastructure.Name] = [ .pia, .tunnelBear ] private let bundle: [Infrastructure.Name: Infrastructure] private let cachePath: URL private var cache: [Infrastructure.Name: Infrastructure] private var lastUpdate: [Infrastructure.Name: Date] private init() { var bundle: [Infrastructure.Name: Infrastructure] = [:] allNames.forEach { bundle[$0] = InfrastructureFactory.embedded(withName: $0) } self.bundle = bundle cachePath = GroupConstants.App.cachesURL cache = [:] lastUpdate = [:] } func loadCache() { let cacheEntries: [URL] do { cacheEntries = try FileManager.default.contentsOfDirectory(at: cachePath, includingPropertiesForKeys: nil) } catch let e { log.verbose("Error loading cache: \(e)") return } let decoder = JSONDecoder() for entry in cacheEntries { guard let data = try? Data(contentsOf: entry) else { continue } guard let infra = try? decoder.decode(Infrastructure.self, from: data) else { continue } // supersede if older than embedded guard InfrastructureFactory.isNewer(cachedEntry: entry, thanBundleWithName: infra.name) else { log.warning("Bundle is newer than cache, superseding cache for \(infra.name)") cache[infra.name] = bundle[infra.name] continue } cache[infra.name] = infra log.debug("Loading cache for \(infra.name)") } } func get(_ name: Infrastructure.Name) -> Infrastructure { guard let infra = cache[name] ?? bundle[name] else { fatalError("No infrastructure embedded nor cached for '\(name)'") } return infra } func update(_ name: Infrastructure.Name, notBeforeInterval minInterval: TimeInterval?, completionHandler: @escaping ((Infrastructure, Date)?, Error?) -> Void) -> Bool { let ifModifiedSince = modificationDate(for: name) if let lastInfrastructureUpdate = lastUpdate[name] { log.debug("Last update for \(name): \(lastUpdate)") if let minInterval = minInterval { let elapsed = -lastInfrastructureUpdate.timeIntervalSinceNow guard elapsed >= minInterval else { log.warning("Skipping update, only \(elapsed) seconds elapsed (< \(minInterval))") return false } } } WebServices.shared.network(with: name, ifModifiedSince: ifModifiedSince) { (response, error) in if error == nil { self.lastUpdate[name] = Date() } guard let response = response else { log.error("No response from web service") DispatchQueue.main.async { completionHandler(nil, error) } return } if response.isCached { log.debug("Cache is up to date") DispatchQueue.main.async { completionHandler(nil, error) } return } guard let infra = response.value, let lastModified = response.lastModified else { log.error("No response from web service or missing Last-Modified") DispatchQueue.main.async { completionHandler(nil, error) } return } let appBuild = GroupConstants.App.buildNumber guard appBuild >= infra.build else { log.error("Response requires app build >= \(infra.build) (found \(appBuild))") DispatchQueue.main.async { completionHandler(nil, error) } return } var isNewer = true if let bundleDate = self.bundleModificationDate(for: name) { log.verbose("Bundle date: \(bundleDate)") log.verbose("Web date: \(lastModified)") isNewer = lastModified > bundleDate } guard isNewer else { log.warning("Web service infrastructure is older than bundle, discarding") DispatchQueue.main.async { completionHandler(nil, error) } return } self.save(name, with: infra, lastModified: lastModified) DispatchQueue.main.async { completionHandler((infra, lastModified), nil) } } return true } func modificationDate(for name: Infrastructure.Name) -> Date? { let optBundleDate = bundleModificationDate(for: name) guard let cacheDate = cacheModificationDate(for: name) else { return optBundleDate } guard let bundleDate = optBundleDate else { return cacheDate } return max(cacheDate, bundleDate) } private func save(_ name: Infrastructure.Name, with infrastructure: Infrastructure, lastModified: Date) { cache[name] = infrastructure let fm = FileManager.default let url = cacheURL(for: name) do { let parent = url.deletingLastPathComponent() try fm.createDirectory(at: parent, withIntermediateDirectories: true, attributes: nil) let data = try JSONEncoder().encode(infrastructure) try data.write(to: url) try fm.setAttributes([.modificationDate: lastModified], ofItemAtPath: url.path) } catch let e { log.error("Error saving cache: \(e)") } } private func cacheURL(for name: Infrastructure.Name) -> URL { return cachePath.appendingPathComponent(name.bundleRelativePath) } private func cacheModificationDate(for name: Infrastructure.Name) -> Date? { let url = cacheURL(for: name) return FileManager.default.modificationDate(of: url.path) } private func bundleModificationDate(for name: Infrastructure.Name) -> Date? { guard let url = name.bundleURL else { return nil } return FileManager.default.modificationDate(of: url.path) } } private extension Infrastructure.Name { var bundleRelativePath: String { let endpoint = WebServices.Endpoint.network(self) // e.g. "Web", PIA="net/pia" -> "Web/net/pia.json" return "\(AppConstants.Store.webCacheDirectory)/\(endpoint.path).json" } var bundleURL: URL? { let endpoint = WebServices.Endpoint.network(self) // e.g. "Web", PIA="net/pia" -> "[Bundle]:Web/net/pia.json" return Bundle.main.url(forResource: "\(AppConstants.Store.webCacheDirectory)/\(endpoint.path)", withExtension: "json") } }