/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import Foundation import UIKit import Shared import Fuzi private let TypeSearch = "text/html" private let TypeSuggest = "application/x-suggestions+json" class OpenSearchEngine: NSObject, NSCoding { static let PreferredIconSize = 30 let shortName: String let engineID: String? let image: UIImage let isCustomEngine: Bool let searchTemplate: String fileprivate let suggestTemplate: String? fileprivate let SearchTermComponent = "{searchTerms}" fileprivate let LocaleTermComponent = "{moz:locale}" fileprivate lazy var searchQueryComponentKey: String? = self.getQueryArgFromTemplate() init(engineID: String?, shortName: String, image: UIImage, searchTemplate: String, suggestTemplate: String?, isCustomEngine: Bool) { self.shortName = shortName self.image = image self.searchTemplate = searchTemplate self.suggestTemplate = suggestTemplate self.isCustomEngine = isCustomEngine self.engineID = engineID } required init?(coder aDecoder: NSCoder) { // this catches the cases where bool encoded in Swift 2 needs to be decoded with decodeObject, but a Bool encoded in swift 3 needs // to be decoded using decodeBool. This catches the upgrade case to ensure that we are always able to fetch a keyed valye for isCustomEngine // http://stackoverflow.com/a/40034694 let isCustomEngine = aDecoder.decodeAsBool(forKey: "isCustomEngine") guard let searchTemplate = aDecoder.decodeObject(forKey: "searchTemplate") as? String, let shortName = aDecoder.decodeObject(forKey: "shortName") as? String, let image = aDecoder.decodeObject(forKey: "image") as? UIImage else { assertionFailure() return nil } self.searchTemplate = searchTemplate self.shortName = shortName self.isCustomEngine = isCustomEngine self.image = image self.engineID = aDecoder.decodeObject(forKey: "engineID") as? String self.suggestTemplate = nil } func encode(with aCoder: NSCoder) { aCoder.encode(searchTemplate, forKey: "searchTemplate") aCoder.encode(shortName, forKey: "shortName") aCoder.encode(isCustomEngine, forKey: "isCustomEngine") aCoder.encode(image, forKey: "image") aCoder.encode(engineID, forKey: "engineID") } /** * Returns the search URL for the given query. */ func searchURLForQuery(_ query: String) -> URL? { return getURLFromTemplate(searchTemplate, query: query) } /** * Return the arg that we use for searching for this engine * Problem: the search terms may not be a query arg, they may be part of the URL - how to deal with this? **/ fileprivate func getQueryArgFromTemplate() -> String? { // we have the replace the templates SearchTermComponent in order to make the template // a valid URL, otherwise we cannot do the conversion to NSURLComponents // and have to do flaky pattern matching instead. let placeholder = "PLACEHOLDER" let template = searchTemplate.replacingOccurrences(of: SearchTermComponent, with: placeholder) let components = URLComponents(string: template) let searchTerm = components?.queryItems?.filter { item in return item.value == placeholder } guard let term = searchTerm, !term.isEmpty else { return nil } return term[0].name } /** * check that the URL host contains the name of the search engine somewhere inside it **/ fileprivate func isSearchURLForEngine(_ url: URL?) -> Bool { guard let urlHost = url?.hostSLD, let queryEndIndex = searchTemplate.range(of: "?")?.lowerBound, let templateURL = URL(string: searchTemplate.substring(to: queryEndIndex)) else { return false } return urlHost == templateURL.hostSLD } /** * Returns the query that was used to construct a given search URL **/ func queryForSearchURL(_ url: URL?) -> String? { if isSearchURLForEngine(url) { if let key = searchQueryComponentKey, let value = url?.getQuery()[key] { return value.replacingOccurrences(of: "+", with: " ").removingPercentEncoding } } return nil } /** * Returns the search suggestion URL for the given query. */ func suggestURLForQuery(_ query: String) -> URL? { if let suggestTemplate = suggestTemplate { return getURLFromTemplate(suggestTemplate, query: query) } return nil } fileprivate func getURLFromTemplate(_ searchTemplate: String, query: String) -> URL? { if let escapedQuery = query.addingPercentEncoding(withAllowedCharacters: CharacterSet.SearchTermsAllowedCharacterSet()) { // Escape the search template as well in case it contains not-safe characters like symbols let templateAllowedSet = NSMutableCharacterSet() templateAllowedSet.formUnion(with: CharacterSet.URLAllowedCharacterSet()) // Allow brackets since we use them in our template as our insertion point templateAllowedSet.formUnion(with: CharacterSet(charactersIn: "{}")) if let encodedSearchTemplate = searchTemplate.addingPercentEncoding(withAllowedCharacters: templateAllowedSet as CharacterSet) { let localeString = Locale.current.identifier let urlString = encodedSearchTemplate .replacingOccurrences(of: SearchTermComponent, with: escapedQuery, options: String.CompareOptions.literal, range: nil) .replacingOccurrences(of: LocaleTermComponent, with: localeString, options: String.CompareOptions.literal, range: nil) return URL(string: urlString) } } return nil } } /** * OpenSearch XML parser. * * This parser accepts standards-compliant OpenSearch 1.1 XML documents in addition to * the Firefox-specific search plugin format. * * OpenSearch spec: http://www.opensearch.org/Specifications/OpenSearch/1.1 */ class OpenSearchParser { fileprivate let pluginMode: Bool init(pluginMode: Bool) { self.pluginMode = pluginMode } func parse(_ file: String, engineID: String) -> OpenSearchEngine? { guard let data = try? Data(contentsOf: URL(fileURLWithPath: file)) else { print("Invalid search file") return nil } guard let indexer = try? XMLDocument(data: data), let docIndexer = indexer.root else { print("Invalid XML document") return nil } let shortNameIndexer = docIndexer.children(tag: "ShortName") if shortNameIndexer.count != 1 { print("ShortName must appear exactly once") return nil } let shortName = shortNameIndexer[0].stringValue if shortName == "" { print("ShortName must contain text") return nil } let urlIndexers = docIndexer.children(tag: "Url") if urlIndexers.isEmpty { print("Url must appear at least once") return nil } var searchTemplate: String! var suggestTemplate: String? for urlIndexer in urlIndexers { let type = urlIndexer.attributes["type"] if type == nil { print("Url element requires a type attribute", terminator: "\n") return nil } if type != TypeSearch && type != TypeSuggest { // Not a supported search type. continue } var template = urlIndexer.attributes["template"] if template == nil { print("Url element requires a template attribute", terminator: "\n") return nil } if pluginMode { let paramIndexers = urlIndexer.children(tag: "Param") if !paramIndexers.isEmpty { template! += "?" var firstAdded = false for paramIndexer in paramIndexers { if firstAdded { template! += "&" } else { firstAdded = true } let name = paramIndexer.attributes["name"] let value = paramIndexer.attributes["value"] if name == nil || value == nil { print("Param element must have name and value attributes", terminator: "\n") return nil } template! += name! + "=" + value! } } } if type == TypeSearch { searchTemplate = template } else { suggestTemplate = template } } if searchTemplate == nil { print("Search engine must have a text/html type") return nil } let imageIndexers = docIndexer.children(tag: "Image") var largestImage = 0 var largestImageElement: XMLElement? // TODO: For now, just use the largest icon. for imageIndexer in imageIndexers { let imageWidth = Int(imageIndexer.attributes["width"] ?? "") let imageHeight = Int(imageIndexer.attributes["height"] ?? "") // Only accept square images. if imageWidth != imageHeight { continue } if let imageWidth = imageWidth { if imageWidth > largestImage { largestImage = imageWidth largestImageElement = imageIndexer } } } let uiImage: UIImage if let imageElement = largestImageElement, let imageURL = URL(string: imageElement.stringValue), let imageData = try? Data(contentsOf: imageURL), let image = UIImage.imageFromDataThreadSafe(imageData) { uiImage = image } else { print("Error: Invalid search image data") return nil } return OpenSearchEngine(engineID: engineID, shortName: shortName, image: uiImage, searchTemplate: searchTemplate, suggestTemplate: suggestTemplate, isCustomEngine: false) } }