codex: add custom search engines #1

Merged
buzzert merged 2 commits from feature/custom-search-engines into master 2025-09-29 23:09:41 +00:00
5 changed files with 108 additions and 39 deletions
Showing only changes of commit 265d393cdc - Show all commits

View File

@@ -795,7 +795,7 @@ extension BrowserViewController: UITextFieldDelegate
currentTab.beginLoadingURL(url) currentTab.beginLoadingURL(url)
} else { } else {
let searchURL = Settings.shared.searchProvider.provider().searchURLWithQuery(text) let searchURL = Settings.shared.currentSearchProvider().searchURLWithQuery(text)
currentTab.beginLoadingURL(searchURL) currentTab.beginLoadingURL(searchURL)
} }

View File

@@ -23,8 +23,8 @@ struct AmberSettingsView: View {
@Environment(\.presentationMode) @Environment(\.presentationMode)
@Binding private var presentationMode @Binding private var presentationMode
@State private var searchProvider = Settings.shared.searchProvider { @State private var defaultSearchEngineName = Settings.shared.defaultSearchEngineName {
didSet { Settings.shared.searchProvider = searchProvider } didSet { Settings.shared.defaultSearchEngineName = defaultSearchEngineName }
} }
var body: some View { var body: some View {
@@ -35,12 +35,12 @@ struct AmberSettingsView: View {
}) })
Section(header: Text("Search Provider"), content: { Section(header: Text("Search Provider"), content: {
ForEach(Settings.SearchProviderSetting.allCases, id: \.self, content: { setting in ForEach(Array(Settings.shared.searchEngines.keys).sorted(), id: \.self, content: { name in
Button(action: { searchProvider = setting }, label: { Button(action: { defaultSearchEngineName = name }, label: {
HStack { HStack {
Text(setting.rawValue) Text(name)
Spacer() Spacer()
if searchProvider == setting { if defaultSearchEngineName == name {
Image(systemName: "checkmark") Image(systemName: "checkmark")
} }
} }

View File

@@ -113,11 +113,16 @@ class GeneralSettingsViewController: UIViewController
typealias Item = String typealias Item = String
static let SearchProviderPopupItem = "searchProvider.popup" static let SearchProviderPopupItem = "searchProvider.popup"
static let SearchEngineNameFieldItem = "searchEngine.add.name"
static let SearchEngineURLFieldItem = "searchEngine.add.url"
static let SyncServerItem = "syncServer.field" static let SyncServerItem = "syncServer.field"
let dataSource: UICollectionViewDiffableDataSource<Section, Item> let dataSource: UICollectionViewDiffableDataSource<Section, Item>
let collectionView: UICollectionView let collectionView: UICollectionView
private var pendingEngineName: String = ""
private var pendingEngineURL: String = ""
static func createLayout(forIdiom idiom: UIUserInterfaceIdiom) -> UICollectionViewLayout { static func createLayout(forIdiom idiom: UIUserInterfaceIdiom) -> UICollectionViewLayout {
#if targetEnvironment(macCatalyst) #if targetEnvironment(macCatalyst)
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
@@ -180,20 +185,45 @@ class GeneralSettingsViewController: UIViewController
init() { init() {
let actionHandler = { (action: UIAction) in let actionHandler = { (action: UIAction) in
let providerString = action.title let engineName = action.title
let provider = Settings.SearchProviderSetting(rawValue: providerString)! Settings.shared.defaultSearchEngineName = engineName
Settings.shared.searchProvider = provider
} }
let itemCellRegistry = UICollectionView.CellRegistration<UICollectionViewCell, Item> { cell, indexPath, identifier in let itemCellRegistry = UICollectionView.CellRegistration<UICollectionViewCell, Item> { cell, indexPath, identifier in
if identifier == Self.SearchProviderPopupItem { if identifier == Self.SearchProviderPopupItem {
let menu = UIMenu(children: Settings.SearchProviderSetting.allCases.map { provider in let names = Settings.shared.searchEngines.keys.sorted()
let action = UIAction(title: provider.rawValue, handler: actionHandler) let menu = UIMenu(children: names.map { name in
action.state = Settings.shared.searchProvider == provider ? .on : .off let action = UIAction(title: name, handler: actionHandler)
action.state = (Settings.shared.defaultSearchEngineName == name) ? .on : .off
return action return action
}) })
cell.contentConfiguration = ButtonContentConfiguration(menu: menu) 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 { } else if identifier == Self.SyncServerItem {
cell.contentConfiguration = TextFieldContentConfiguration( cell.contentConfiguration = TextFieldContentConfiguration(
text: Settings.shared.syncServer ?? "", text: Settings.shared.syncServer ?? "",
@@ -248,14 +278,7 @@ class GeneralSettingsViewController: UIViewController
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
applySnapshot(animatingDifferences: false)
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)
} }
} }
@@ -265,3 +288,33 @@ extension GeneralSettingsViewController : UICollectionViewDelegate {
false false
} }
} }
private extension GeneralSettingsViewController {
func applySnapshot(animatingDifferences: Bool) {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
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)
}
}

View File

@@ -75,25 +75,29 @@ class Settings
{ {
static let shared = Settings() static let shared = Settings()
public enum SearchProviderSetting: String, CaseIterable { // Map of search engine name -> URL template containing %q or %s placeholder
case google = "Google" // Defaults preserve the previous built-in engines
case duckduckgo = "DuckDuckGo" @SettingProperty(key: "searchEngines")
case searxnor = "Searx.nor" public var searchEngines: [String: String] = [
case whoogle = "Whoogle.nor" "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"
]
func provider() -> SearchProvider { // Name of the default search engine from `searchEngines`
switch self { @SettingProperty(key: "defaultSearchEngine")
case .google: return SearchProvider.google public var defaultSearchEngineName: String = "Searx.nor"
case .duckduckgo: return SearchProvider.duckduckgo
case .searxnor: return SearchProvider.searxnor // Convenience to build a SearchProvider from current default
case .whoogle: return SearchProvider.whoogle 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") @SettingProperty(key: "redirectRules")
public var redirectRules: [String: String] = [:] public var redirectRules: [String: String] = [:]

View File

@@ -9,6 +9,18 @@ import Foundation
class SearchProvider 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 static let google = SearchProvider(resolver: { query in
// gbv=1: no JS // gbv=1: no JS
URL(string: "https://google.com/search?q=\(query.sanitized())&gbv=1")! URL(string: "https://google.com/search?q=\(query.sanitized())&gbv=1")!