diff --git a/App/Browser View/BrowserViewController.swift b/App/Browser View/BrowserViewController.swift index c5cef7a..2988efe 100644 --- a/App/Browser View/BrowserViewController.swift +++ b/App/Browser View/BrowserViewController.swift @@ -795,7 +795,7 @@ extension BrowserViewController: UITextFieldDelegate currentTab.beginLoadingURL(url) } else { - let searchURL = Settings.shared.searchProvider.provider().searchURLWithQuery(text) + let searchURL = Settings.shared.currentSearchProvider().searchURLWithQuery(text) currentTab.beginLoadingURL(searchURL) } diff --git a/App/Settings/Amber/AmberSettingsView.swift b/App/Settings/Amber/AmberSettingsView.swift index c0d080d..5c9014d 100644 --- a/App/Settings/Amber/AmberSettingsView.swift +++ b/App/Settings/Amber/AmberSettingsView.swift @@ -23,8 +23,8 @@ struct AmberSettingsView: View { @Environment(\.presentationMode) @Binding private var presentationMode - @State private var searchProvider = Settings.shared.searchProvider { - didSet { Settings.shared.searchProvider = searchProvider } + @State private var defaultSearchEngineName = Settings.shared.defaultSearchEngineName { + didSet { Settings.shared.defaultSearchEngineName = defaultSearchEngineName } } var body: some View { @@ -35,12 +35,12 @@ struct AmberSettingsView: View { }) Section(header: Text("Search Provider"), content: { - ForEach(Settings.SearchProviderSetting.allCases, id: \.self, content: { setting in - Button(action: { searchProvider = setting }, label: { + ForEach(Array(Settings.shared.searchEngines.keys).sorted(), id: \.self, content: { name in + Button(action: { defaultSearchEngineName = name }, label: { HStack { - Text(setting.rawValue) + Text(name) Spacer() - if searchProvider == setting { + if defaultSearchEngineName == name { Image(systemName: "checkmark") } } diff --git a/App/Settings/GeneralSettingsViewController.swift b/App/Settings/GeneralSettingsViewController.swift index e8e96af..dae5aa8 100644 --- a/App/Settings/GeneralSettingsViewController.swift +++ b/App/Settings/GeneralSettingsViewController.swift @@ -113,11 +113,16 @@ class GeneralSettingsViewController: UIViewController typealias Item = String static let SearchProviderPopupItem = "searchProvider.popup" + static let SearchEngineNameFieldItem = "searchEngine.add.name" + static let SearchEngineURLFieldItem = "searchEngine.add.url" static let SyncServerItem = "syncServer.field" let dataSource: UICollectionViewDiffableDataSource let collectionView: UICollectionView + private var pendingEngineName: String = "" + private var pendingEngineURL: String = "" + static func createLayout(forIdiom idiom: UIUserInterfaceIdiom) -> UICollectionViewLayout { #if targetEnvironment(macCatalyst) let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), @@ -180,20 +185,45 @@ class GeneralSettingsViewController: UIViewController init() { let actionHandler = { (action: UIAction) in - let providerString = action.title - let provider = Settings.SearchProviderSetting(rawValue: providerString)! - Settings.shared.searchProvider = provider + let engineName = action.title + Settings.shared.defaultSearchEngineName = engineName } let itemCellRegistry = UICollectionView.CellRegistration { cell, indexPath, identifier in if identifier == Self.SearchProviderPopupItem { - let menu = UIMenu(children: Settings.SearchProviderSetting.allCases.map { provider in - let action = UIAction(title: provider.rawValue, handler: actionHandler) - action.state = Settings.shared.searchProvider == provider ? .on : .off + let names = Settings.shared.searchEngines.keys.sorted() + let menu = UIMenu(children: names.map { name in + let action = UIAction(title: name, handler: actionHandler) + action.state = (Settings.shared.defaultSearchEngineName == name) ? .on : .off return action }) - cell.contentConfiguration = ButtonContentConfiguration(menu: menu) + } else if identifier == Self.SearchEngineNameFieldItem { + cell.contentConfiguration = TextFieldContentConfiguration( + text: self.pendingEngineName, + placeholderText: "Name (e.g., Startpage)", + textChanged: { [weak self] newString in + self?.pendingEngineName = newString + }, + pressedReturn: { $0.resignFirstResponder() }, + keyboardType: .default, + returnKeyType: .next + ) + } else if identifier == Self.SearchEngineURLFieldItem { + cell.contentConfiguration = TextFieldContentConfiguration( + text: self.pendingEngineURL, + placeholderText: "URL template (use %q or %s)", + textChanged: { [weak self] newString in + self?.pendingEngineURL = newString + }, + pressedReturn: { [weak self] textField in + guard let self = self else { return } + self.tryAddPendingSearchEngine() + textField.resignFirstResponder() + }, + keyboardType: .URL, + returnKeyType: .done + ) } else if identifier == Self.SyncServerItem { cell.contentConfiguration = TextFieldContentConfiguration( text: Settings.shared.syncServer ?? "", @@ -248,14 +278,7 @@ class GeneralSettingsViewController: UIViewController override func viewDidLoad() { super.viewDidLoad() - - var snapshot = dataSource.snapshot() - snapshot.appendSections(Section.allCases) - // iOS - // snapshot.appendItems(Settings.SearchProviderSetting.allCases.map { $0.rawValue }, toSection: .searchEngine) - snapshot.appendItems([ Self.SearchProviderPopupItem ], toSection: .searchEngine) - snapshot.appendItems([ Self.SyncServerItem ], toSection: .syncServer) - dataSource.apply(snapshot, animatingDifferences: false) + applySnapshot(animatingDifferences: false) } } @@ -265,3 +288,33 @@ extension GeneralSettingsViewController : UICollectionViewDelegate { false } } + +private extension GeneralSettingsViewController { + func applySnapshot(animatingDifferences: Bool) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections(Section.allCases) + snapshot.appendItems([ Self.SearchProviderPopupItem, + Self.SearchEngineNameFieldItem, + Self.SearchEngineURLFieldItem ], toSection: .searchEngine) + snapshot.appendItems([ Self.SyncServerItem ], toSection: .syncServer) + dataSource.apply(snapshot, animatingDifferences: animatingDifferences) + } + + func tryAddPendingSearchEngine() { + let name = pendingEngineName.trimmingCharacters(in: .whitespacesAndNewlines) + let url = pendingEngineURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty, !url.isEmpty else { return } + // Require placeholder + guard url.contains("%q") || url.contains("%s") else { return } + + // Save + var engines = Settings.shared.searchEngines + engines[name] = url + Settings.shared.searchEngines = engines + + // Reset inputs and refresh UI + pendingEngineName = "" + pendingEngineURL = "" + applySnapshot(animatingDifferences: true) + } +} diff --git a/App/Settings/Settings.swift b/App/Settings/Settings.swift index c0983e8..9560977 100644 --- a/App/Settings/Settings.swift +++ b/App/Settings/Settings.swift @@ -74,26 +74,30 @@ extension String: RawRepresentable { class Settings { static let shared = Settings() - - public enum SearchProviderSetting: String, CaseIterable { - case google = "Google" - case duckduckgo = "DuckDuckGo" - case searxnor = "Searx.nor" - case whoogle = "Whoogle.nor" - - func provider() -> SearchProvider { - switch self { - case .google: return SearchProvider.google - case .duckduckgo: return SearchProvider.duckduckgo - case .searxnor: return SearchProvider.searxnor - case .whoogle: return SearchProvider.whoogle - } + + // Map of search engine name -> URL template containing %q or %s placeholder + // Defaults preserve the previous built-in engines + @SettingProperty(key: "searchEngines") + public var searchEngines: [String: String] = [ + "Google": "https://google.com/search?q=%q&gbv=1", + "DuckDuckGo": "https://html.duckduckgo.com/html/?q=%q", + "Searx.nor": "http://searx.nor/search?q=%q&categories=general", + "Whoogle.nor": "http://whoogle.nor/search?q=%q" + ] + + // Name of the default search engine from `searchEngines` + @SettingProperty(key: "defaultSearchEngine") + public var defaultSearchEngineName: String = "Searx.nor" + + // Convenience to build a SearchProvider from current default + func currentSearchProvider() -> SearchProvider { + if let template = searchEngines[defaultSearchEngineName] { + return SearchProvider.fromTemplate(template) } + // Fallback to Google if something goes wrong + return SearchProvider.fromTemplate("https://google.com/search?q=%q&gbv=1") } - @SettingProperty(key: "searchProvider") - public var searchProvider: SearchProviderSetting = .searxnor - @SettingProperty(key: "redirectRules") public var redirectRules: [String: String] = [:] diff --git a/App/Web Search/SearchProvider.swift b/App/Web Search/SearchProvider.swift index 1fd3c1b..da80436 100644 --- a/App/Web Search/SearchProvider.swift +++ b/App/Web Search/SearchProvider.swift @@ -9,6 +9,18 @@ import Foundation class SearchProvider { + // Build a provider from a URL template. Template should contain %q or %s + // which will be replaced with the sanitized query string. + static func fromTemplate(_ template: String) -> SearchProvider { + SearchProvider(resolver: { query in + let sanitized = query.sanitized() + let replaced = template + .replacingOccurrences(of: "%q", with: sanitized) + .replacingOccurrences(of: "%s", with: sanitized) + return URL(string: replaced) ?? URL(string: "https://google.com/search?q=\(sanitized)&gbv=1")! + }) + } + static let google = SearchProvider(resolver: { query in // gbv=1: no JS URL(string: "https://google.com/search?q=\(query.sanitized())&gbv=1")!