final class IP2Country { var countryNamesCache: [String:String] = [:] private static let workQueue = DispatchQueue(label: "IP2Country.workQueue", qos: .utility) // It's important that this is a serial queue static var isInitialized = false // MARK: Tables /// This table has two columns: the "network" column and the "registered_country_geoname_id" column. The network column contains the **lower** bound of an IP /// range and the "registered_country_geoname_id" column contains the ID of the country corresponding to that range. We look up an IP by finding the first index in the /// network column where the value is greater than the IP we're looking up (converted to an integer). The IP we're looking up must then be in the range **before** that /// range. private lazy var ipv4Table: [String:[Int]] = { let url = Bundle.main.url(forResource: "GeoLite2-Country-Blocks-IPv4", withExtension: nil)! let data = try! Data(contentsOf: url) return try! NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as! [String:[Int]] }() private lazy var countryNamesTable: [String:[String]] = { let url = Bundle.main.url(forResource: "GeoLite2-Country-Locations-English", withExtension: nil)! let data = try! Data(contentsOf: url) return try! NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as! [String:[String]] }() // MARK: Lifecycle static let shared = IP2Country() private init() { NotificationCenter.default.addObserver(self, selector: #selector(populateCacheIfNeededAsync), name: .pathsBuilt, object: nil) } deinit { NotificationCenter.default.removeObserver(self) } // MARK: Implementation private func cacheCountry(for ip: String) -> String { if let result = countryNamesCache[ip] { return result } let ipAsInt = IPv4.toInt(ip) guard let ipv4TableIndex = given(ipv4Table["network"]!.firstIndex(where: { $0 > ipAsInt }), { $0 - 1 }) else { return "Unknown Country" } // Relies on the array being sorted let countryID = ipv4Table["registered_country_geoname_id"]![ipv4TableIndex] guard let countryNamesTableIndex = countryNamesTable["geoname_id"]!.firstIndex(of: String(countryID)) else { return "Unknown Country" } let result = countryNamesTable["country_name"]![countryNamesTableIndex] countryNamesCache[ip] = result return result } @objc func populateCacheIfNeededAsync() { // This has to be sync since the `countryNamesCache` dict doesn't like async access IP2Country.workQueue.sync { let _ = self.populateCacheIfNeeded() } } func populateCacheIfNeeded() -> Bool { if OnionRequestAPI.paths.isEmpty { OnionRequestAPI.paths = Storage.shared.getOnionRequestPaths() } let paths = OnionRequestAPI.paths guard !paths.isEmpty else { return false } let pathToDisplay = paths.first! pathToDisplay.forEach { snode in let _ = self.cacheCountry(for: snode.ip) // Preload if needed } DispatchQueue.main.async { IP2Country.isInitialized = true NotificationCenter.default.post(name: .onionRequestPathCountriesLoaded, object: nil) } SNLog("Finished preloading onion request path countries.") return true } }