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)
} else {
let searchURL = Settings.shared.searchProvider.provider().searchURLWithQuery(text)
let searchURL = Settings.shared.currentSearchProvider().searchURLWithQuery(text)
currentTab.beginLoadingURL(searchURL)
}

View File

@@ -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")
}
}

View File

@@ -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<Section, Item>
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<UICollectionViewCell, Item> { 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<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()
public enum SearchProviderSetting: String, CaseIterable {
case google = "Google"
case duckduckgo = "DuckDuckGo"
case searxnor = "Searx.nor"
case whoogle = "Whoogle.nor"
// 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"
]
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
}
// 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] = [:]

View File

@@ -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")!