Dactyloidae/mobile/ios/Client/Frontend/Browser/SearchEngines.swift
2026-06-26 21:04:09 -07:00

291 lines
12 KiB
Swift

/* 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 Shared
import Storage
import XCGLogger
private let log = Logger.browserLogger
private let OrderedEngineNames = "search.orderedEngineNames"
private let DisabledEngineNames = "search.disabledEngineNames"
private let ShowSearchSuggestionsOptIn = "search.suggestions.showOptIn"
private let ShowSearchSuggestions = "search.suggestions.show"
private let customSearchEnginesFileName = "customEngines.plist"
/**
* Manage a set of Open Search engines.
*
* The search engines are ordered. Individual search engines can be enabled and disabled. The
* first search engine is distinguished and labeled the "default" search engine; it can never be
* disabled. Search suggestions should always be sourced from the default search engine.
*
* Two additional bits of information are maintained: whether the user should be shown "opt-in to
* search suggestions" UI, and whether search suggestions are enabled.
*
* Consumers will almost always use `defaultEngine` if they want a single search engine, and
* `quickSearchEngines()` if they want a list of enabled quick search engines (possibly empty,
* since the default engine is never included in the list of enabled quick search engines, and
* it is possible to disable every non-default quick search engine).
*
* The search engines are backed by a write-through cache into a ProfilePrefs instance. This class
* is not thread-safe -- you should only access it on a single thread (usually, the main thread)!
*/
class SearchEngines {
fileprivate let prefs: Prefs
fileprivate let fileAccessor: FileAccessor
init(prefs: Prefs, files: FileAccessor) {
self.prefs = prefs
// By default, show search suggestions
self.shouldShowSearchSuggestions = prefs.boolForKey(ShowSearchSuggestions) ?? true
self.fileAccessor = files
self.disabledEngineNames = getDisabledEngineNames()
self.orderedEngines = getOrderedEngines()
}
var defaultEngine: OpenSearchEngine {
get {
return self.orderedEngines[0]
}
set(defaultEngine) {
// The default engine is always enabled.
self.enableEngine(defaultEngine)
// The default engine is always first in the list.
var orderedEngines = self.orderedEngines.filter { engine in engine.shortName != defaultEngine.shortName }
orderedEngines.insert(defaultEngine, at: 0)
self.orderedEngines = orderedEngines
}
}
func isEngineDefault(_ engine: OpenSearchEngine) -> Bool {
return defaultEngine.shortName == engine.shortName
}
// The keys of this dictionary are used as a set.
fileprivate var disabledEngineNames: [String: Bool]! {
didSet {
self.prefs.setObject(Array(self.disabledEngineNames.keys), forKey: DisabledEngineNames)
}
}
var orderedEngines: [OpenSearchEngine]! {
didSet {
self.prefs.setObject(self.orderedEngines.map { $0.shortName }, forKey: OrderedEngineNames)
}
}
var quickSearchEngines: [OpenSearchEngine]! {
get {
return self.orderedEngines.filter({ (engine) in !self.isEngineDefault(engine) && self.isEngineEnabled(engine) })
}
}
var shouldShowSearchSuggestions: Bool {
didSet {
self.prefs.setObject(shouldShowSearchSuggestions, forKey: ShowSearchSuggestions)
}
}
func isEngineEnabled(_ engine: OpenSearchEngine) -> Bool {
return disabledEngineNames.index(forKey: engine.shortName) == nil
}
func enableEngine(_ engine: OpenSearchEngine) {
disabledEngineNames.removeValue(forKey: engine.shortName)
}
func disableEngine(_ engine: OpenSearchEngine) {
if isEngineDefault(engine) {
// Can't disable default engine.
return
}
disabledEngineNames[engine.shortName] = true
}
func deleteCustomEngine(_ engine: OpenSearchEngine) {
// We can't delete a preinstalled engine or an engine that is currently the default.
if !engine.isCustomEngine || isEngineDefault(engine) {
return
}
customEngines.remove(at: customEngines.index(of: engine)!)
saveCustomEngines()
orderedEngines = getOrderedEngines()
}
/// Adds an engine to the front of the search engines list.
func addSearchEngine(_ engine: OpenSearchEngine) {
customEngines.append(engine)
orderedEngines.insert(engine, at: 1)
saveCustomEngines()
}
func queryForSearchURL(_ url: URL?) -> String? {
for engine in orderedEngines {
guard let searchTerm = engine.queryForSearchURL(url) else { continue }
return searchTerm
}
return nil
}
fileprivate func getDisabledEngineNames() -> [String: Bool] {
if let disabledEngineNames = self.prefs.stringArrayForKey(DisabledEngineNames) {
var disabledEngineDict = [String: Bool]()
for engineName in disabledEngineNames {
disabledEngineDict[engineName] = true
}
return disabledEngineDict
} else {
return [String: Bool]()
}
}
fileprivate func customEngineFilePath() -> String {
let profilePath = try! self.fileAccessor.getAndEnsureDirectory() as NSString
return profilePath.appendingPathComponent(customSearchEnginesFileName)
}
fileprivate lazy var customEngines: [OpenSearchEngine] = {
return NSKeyedUnarchiver.unarchiveObject(withFile: self.customEngineFilePath()) as? [OpenSearchEngine] ?? []
}()
fileprivate func saveCustomEngines() {
NSKeyedArchiver.archiveRootObject(customEngines, toFile: self.customEngineFilePath())
}
/// Return all possible paths for a language identifier in the order of most specific to least specific.
/// For example, zh-Hans-CN with a default of en will return [zh-Hans-CN, zh-CN, zh, en]. The fallback
/// identifier must be a known one that is guaranteed to exist in the SearchPlugins directory.
class func directoriesForLanguageIdentifier(_ languageIdentifier: String, basePath: NSString, fallbackIdentifier: String) -> [String] {
var directories = [String]()
let components = languageIdentifier.components(separatedBy: "-")
if components.count == 1 {
// zh
directories.append(languageIdentifier)
} else if components.count == 2 {
// zh-CN
directories.append(languageIdentifier)
directories.append(components[0])
} else if components.count == 3 {
directories.append(languageIdentifier)
directories.append(components[0] + "-" + components[2])
directories.append(components[0])
}
if !directories.contains(fallbackIdentifier) {
directories.append(fallbackIdentifier)
}
return directories.map { (path) -> String in
return basePath.appendingPathComponent(path)
}
}
// Return the language identifier to be used for the search engine selection. This returns the first
// identifier from preferredLanguages and takes into account that on iOS 8, zh-Hans-CN is returned as
// zh-Hans. In that case it returns the longer form zh-Hans-CN. Same for traditional Chinese.
//
// These exceptions can go away when we drop iOS 8 or when we start using a better mechanism for search
// engine selection that is not based on language identifier.
class func languageIdentifierForSearchEngines() -> String {
let languageIdentifier = Locale.preferredLanguages.first!
switch languageIdentifier {
case "zh-Hans":
return "zh-Hans-CN"
case "zh-Hant":
return "zh-Hant-TW"
default:
return languageIdentifier
}
}
/// Get all bundled (not custom) search engines, with the default search engine first,
/// but the others in no particular order.
class func getUnorderedBundledEngines() -> [OpenSearchEngine] {
let pluginBasePath: NSString = (Bundle.main.resourcePath! as NSString).appendingPathComponent("SearchPlugins") as NSString
let languageIdentifier = languageIdentifierForSearchEngines()
let fallbackDirectory: NSString = pluginBasePath.appendingPathComponent("en") as NSString
var directory: String?
for path in directoriesForLanguageIdentifier(languageIdentifier, basePath: pluginBasePath, fallbackIdentifier: "en") {
if FileManager.default.fileExists(atPath: path) {
directory = path
break
}
}
// This cannot happen if we include the fallback, but if it does we return no engines at all
guard let searchDirectory = directory else {
return []
}
let index = (searchDirectory as NSString).appendingPathComponent("list.txt")
let listFile = try? String(contentsOfFile: index, encoding: String.Encoding.utf8)
assert(listFile != nil, "Read the list of search engines")
let engineNames = listFile!
.trimmingCharacters(in: CharacterSet.newlines)
.components(separatedBy: CharacterSet.newlines)
var engines = [OpenSearchEngine]()
let parser = OpenSearchParser(pluginMode: true)
for engineName in engineNames {
// Ignore hidden engines in list.txt
if engineName.endsWith(":hidden") {
continue
}
// Search the current localized search plugins directory for the search engine.
// If it doesn't exist, fall back to English.
var fullPath = (searchDirectory as NSString).appendingPathComponent("\(engineName).xml")
if !FileManager.default.fileExists(atPath: fullPath) {
fullPath = fallbackDirectory.appendingPathComponent("\(engineName).xml")
}
assert(FileManager.default.fileExists(atPath: fullPath), "\(fullPath) exists")
guard let engine = parser.parse(fullPath, engineID: engineName) else {
log.error("Failed to parse search engine ID \(engineName) at \(fullPath)")
continue
}
engines.append(engine)
}
let defaultEngineFile = (searchDirectory as NSString).appendingPathComponent("default.txt")
let defaultEngineName = try? String(contentsOfFile: defaultEngineFile, encoding: String.Encoding.utf8).trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
return engines.sorted { e, _ in e.shortName == defaultEngineName }
}
/// Get all known search engines, possibly as ordered by the user.
fileprivate func getOrderedEngines() -> [OpenSearchEngine] {
let unorderedEngines = customEngines + SearchEngines.getUnorderedBundledEngines()
guard let orderedEngineNames = prefs.stringArrayForKey(OrderedEngineNames) else {
// We haven't persisted the engine order, so return whatever order we got from disk.
return unorderedEngines
}
// We have a persisted order of engines, so try to use that order.
// We may have found engines that weren't persisted in the ordered list
// (if the user changed locales or added a new engine); these engines
// will be appended to the end of the list.
return unorderedEngines.sorted { engine1, engine2 in
let index1 = orderedEngineNames.index(of: engine1.shortName)
let index2 = orderedEngineNames.index(of: engine2.shortName)
if index1 == nil && index2 == nil {
return engine1.shortName < engine2.shortName
}
// nil < N for all non-nil values of N.
if index1 == nil || index2 == nil {
return index1 ?? -1 > index2 ?? -1
}
return index1! < index2!
}
}
}