passepartout-apple/Passepartout/Core/Sources/Services/InfrastructureFactory.swift

363 lines
13 KiB
Swift

//
// InfrastructureFactory.swift
// Passepartout
//
// Created by Davide De Rosa on 9/2/18.
// Copyright (c) 2021 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 <http://www.gnu.org/licenses/>.
//
import Foundation
import SwiftyBeaver
private let log = SwiftyBeaver.self
// TODO: retain max N infrastructures at a time (LRU)
public class InfrastructureFactory {
public static let shared = InfrastructureFactory()
private let cachePath: URL
fileprivate var cachedMetadata: [Infrastructure.Metadata]
private var cachedInfrastructures: [Infrastructure.Name: Infrastructure]
private var lastUpdate: [Infrastructure.Name: Date]
private init() {
cachePath = GroupConstants.App.cachesURL
cachedMetadata = []
cachedInfrastructures = [:]
lastUpdate = [:]
}
// MARK: Storage
public func preload() {
loadMetadata()
loadInfrastructures()
}
public func loadMetadata() {
let decoder = JSONDecoder()
// pick cache if newer
if Utils.isFile(at: cacheMetadataURL, newerThanFileAt: bundledMetadataURL) {
do {
let indexData = try Data(contentsOf: cacheMetadataURL)
cachedMetadata = try decoder.decode([Infrastructure.Metadata].self, from: indexData)
log.debug("Loaded metadata from cache: \(cachedMetadata)")
return
} catch let e {
log.warning("No index in cache: \(e)")
}
} else {
log.warning("Bundle is newer than cache, superseding cache for index")
}
// fall back to bundled index
guard let bundleURL = bundledMetadataURL else {
fatalError("Unable to build index bundleURL")
}
do {
let indexData = try Data(contentsOf: bundleURL)
cachedMetadata = try decoder.decode([Infrastructure.Metadata].self, from: indexData)
log.debug("Loaded index from bundle: \(cachedMetadata)")
} catch let e {
log.error("Unable to load index from bundle: \(e)")
}
}
public func loadInfrastructures() {
let apiPath = cachePath.appendingPathComponent(AppConstants.Store.apiDirectory)
let providersPath = apiPath.appendingPathComponent(WebServices.Group.providers.rawValue)
log.debug("Loading cache from: \(providersPath)")
let cacheProvidersEntries: [URL]
do {
cacheProvidersEntries = try FileManager.default.contentsOfDirectory(at: providersPath, includingPropertiesForKeys: nil)
} catch let e {
log.warning("Error loading cache or nothing cached: \(e)")
cachedMetadata.forEach {
guard let infra = bundledInfrastructure(withName: $0.name) else {
log.warning("Missing infrastructure \($0.name) from bundle")
return
}
cachedInfrastructures[$0.name] = infra
log.debug("Loaded infrastructure \($0.name) from bundle")
}
return
}
let decoder = JSONDecoder()
for entry in cacheProvidersEntries {
let name = entry.lastPathComponent
// skip *.json (index.json presumably)
guard !name.hasSuffix(".json") else {
continue
}
// pick cache if newer
if Utils.isFile(at: entry, newerThanFileAt: name.bundleURL) {
let infraPath = WebServices.Endpoint.providerNetwork(name).apiURL(relativeTo: cachePath)
do {
let infraData = try Data(contentsOf: infraPath)
let infra = try decoder.decode(Infrastructure.self, from: infraData)
cachedInfrastructures[name] = infra
log.debug("Loaded infrastructure \(name) from cache")
continue
} catch let e {
log.warning("Unable to load infrastructure \(entry.lastPathComponent): \(e)")
// if let json = String(data: data, encoding: .utf8) {
// log.warning(json)
// }
}
} else {
log.warning("Bundle is newer than cache, superseding cache for \(name)")
}
// fall back to bundle
guard let infra = bundledInfrastructure(withName: name) else {
log.warning("Missing infrastructure \(name) from bundle")
continue
}
cachedInfrastructures[name] = infra
log.debug("Loaded infrastructure \(name) from bundle")
}
// fill up with bundled
cachedMetadata.forEach {
if cachedInfrastructures[$0.name] == nil {
guard let infra = bundledInfrastructure(withName: $0.name) else {
log.warning("Missing infrastructure \($0.name) from bundle")
return
}
cachedInfrastructures[$0.name] = infra
log.debug("Loaded infrastructure \($0.name) from bundle")
}
}
}
public var allMetadata: [Infrastructure.Metadata] {
return cachedMetadata
}
public func metadata(forName name: Infrastructure.Name) -> Infrastructure.Metadata? {
return cachedMetadata.first(where: { $0.name == name})
}
public func infrastructure(forName name: Infrastructure.Name) -> Infrastructure? {
return cachedInfrastructures[name]
}
private func bundledInfrastructure(withName name: Infrastructure.Name) -> Infrastructure? {
guard let url = name.bundleURL else {
return nil
}
do {
return try Infrastructure.from(url: url)
} catch let e {
fatalError("Cannot parse JSON for infrastructure '\(name)': \(e)")
}
}
// MARK: Web services
public func updateIndex(completionHandler: @escaping (Error?) -> Void) {
WebServices.shared.providersIndex {
if let response = $0 {
self.saveIndex(with: response)
}
completionHandler($1)
}
}
public func update(_ name: Infrastructure.Name, notBeforeInterval minInterval: TimeInterval?, completionHandler: @escaping ((Infrastructure, Date)?, Error?) -> Void) -> Bool {
let ifModifiedSince = modificationDate(forName: 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.providerNetwork(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.buildNumber 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(forName: 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
}
private func saveIndex(with metadata: [Infrastructure.Metadata]) {
cachedMetadata = metadata
let fm = FileManager.default
let url = cacheMetadataURL
do {
let parent = url.deletingLastPathComponent()
try fm.createDirectory(at: parent, withIntermediateDirectories: true, attributes: nil)
let data = try JSONEncoder().encode(metadata)
try data.write(to: url)
} catch let e {
log.error("Error saving index to cache: \(e)")
}
}
private func save(_ name: Infrastructure.Name, with infrastructure: Infrastructure, lastModified: Date) {
cachedInfrastructures[name] = infrastructure
let fm = FileManager.default
let url = cacheURL(forName: 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 infrastructure \(name) to cache: \(e)")
}
}
// MARK: URLs
private var cacheMetadataURL: URL {
return WebServices.Endpoint.providersIndex.apiURL(relativeTo: cachePath)
}
private func cacheURL(forName name: Infrastructure.Name) -> URL {
return WebServices.Endpoint.providerNetwork(name).apiURL(relativeTo: cachePath)
}
private var bundledMetadataURL: URL? {
return WebServices.Endpoint.providersIndex.bundleURL(in: Bundle(for: InfrastructureFactory.self))
}
// MARK: Modification dates
public func modificationDate(forName name: Infrastructure.Name) -> Date? {
let optBundleDate = bundleModificationDate(forName: name)
guard let cacheDate = cacheModificationDate(forName: name) else {
return optBundleDate
}
guard let bundleDate = optBundleDate else {
return cacheDate
}
return max(cacheDate, bundleDate)
}
private func cacheModificationDate(forName name: Infrastructure.Name) -> Date? {
let url = cacheURL(forName: name)
return FileManager.default.modificationDate(of: url.path)
}
private func bundleModificationDate(forName name: Infrastructure.Name) -> Date? {
guard let url = name.bundleURL else {
return nil
}
return FileManager.default.modificationDate(of: url.path)
}
}
extension Infrastructure {
public var metadata: Metadata? {
return InfrastructureFactory.shared.metadata(forName: name)
}
}
private extension Infrastructure.Name {
var bundleURL: URL? {
return WebServices.Endpoint.providerNetwork(self).bundleURL(in: Bundle(for: InfrastructureFactory.self))
}
}
extension ConnectionService {
public func availableProviders() -> [Infrastructure.Metadata] {
let names = Set(ids(forContext: .provider))
return InfrastructureFactory.shared.cachedMetadata.filter { !names.contains($0.name) }
}
public func hasAvailableProviders() -> Bool {
var allNames = Set(InfrastructureFactory.shared.cachedMetadata.map { $0.name })
allNames.subtract(ids(forContext: .provider))
return !allNames.isEmpty
}
}