From f8a4ab0575075ee40e414ad2362c7f5c0303afa3 Mon Sep 17 00:00:00 2001 From: JaggedGem Date: Mon, 8 Jun 2026 14:05:54 +0300 Subject: [PATCH 1/9] feat: add DuckDuckGo as a search engine - add node scripts to start the browser with omnibox devtools - improve typing to better handle multiple search engines --- package.json | 2 + .../utils/browser/omnibox.ts | 4 +- .../search-providers/duckduckgo.ts | 97 +++++++++++++++++++ .../omnibox-new/search-providers/google.ts | 11 ++- .../lib/omnibox-new/search-providers/index.ts | 13 ++- .../lib/omnibox-new/search-providers/types.ts | 15 ++- 6 files changed, 128 insertions(+), 14 deletions(-) create mode 100644 src/renderer/src/lib/omnibox-new/search-providers/duckduckgo.ts diff --git a/package.json b/package.json index c0eb8c4e..017de34c 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "dev": "electron-vite dev", "dev:watch": "electron-vite dev --watch", "dev:devtools": "cross-env BROWSER_WINDOW_DEVTOOLS=true bun run dev", + "dev:omnibox-devtools": "cross-env OMNIBOX_DEVTOOLS=true bun run dev", + "dev:all-devtools": "cross-env BROWSER_WINDOW_DEVTOOLS=true OMNIBOX_DEVTOOLS=true bun run dev", "build": "bun run typecheck && cross-env PRODUCTION_BUILD=true electron-vite build && bun run script:prune-frontend-routes", "build:unpack": "bun run build && electron-builder --dir", "build:win": "bun run build && electron-builder --win", diff --git a/src/main/controllers/windows-controller/utils/browser/omnibox.ts b/src/main/controllers/windows-controller/utils/browser/omnibox.ts index ae9b1684..08178428 100644 --- a/src/main/controllers/windows-controller/utils/browser/omnibox.ts +++ b/src/main/controllers/windows-controller/utils/browser/omnibox.ts @@ -1,4 +1,4 @@ -import { BrowserWindow as ElectronBrowserWindow, Rectangle, WebContents, WebContentsView } from "electron"; +import { app, BrowserWindow as ElectronBrowserWindow, Rectangle, WebContents, WebContentsView } from "electron"; import { debugPrint } from "@/modules/output"; import { clamp } from "@/modules/utils"; import { browserWindowsController } from "@/controllers/windows-controller/interfaces/browser"; @@ -34,7 +34,7 @@ type PaddedBounds = { shadowPadding: OmniboxShadowPadding; }; -const OMNIBOX_OPEN_DEVTOOLS = false; +const OMNIBOX_OPEN_DEVTOOLS = !app.isPackaged && !!process.env.OMNIBOX_DEVTOOLS; function normalizeBounds(bounds: Electron.Rectangle, windowBounds: Rectangle): Rectangle { const width = clamp(Math.round(bounds.width), 0, windowBounds.width); diff --git a/src/renderer/src/lib/omnibox-new/search-providers/duckduckgo.ts b/src/renderer/src/lib/omnibox-new/search-providers/duckduckgo.ts new file mode 100644 index 00000000..40681711 --- /dev/null +++ b/src/renderer/src/lib/omnibox-new/search-providers/duckduckgo.ts @@ -0,0 +1,97 @@ +import type { QuerySearchProviderCompletion, SearchProvider, SearchProviderRequest } from "./types"; + +type RawDuckDuckGoResponse = [string, string[]]; + +interface DuckDuckGoSuggestion { + phrase: string; +} + +interface DuckDuckGoSuggestionResponse { + query: string; + suggestions: DuckDuckGoSuggestion[] | null; +} + +const DUCKDUCKGO_SEARCH_BASE_URL = "https://www.duckduckgo.com"; +const DUCKDUCKGO_SUGGEST_BASE_URL = "https://duckduckgo.com/ac/"; + +function buildSearchUrl(query: string, aiEnabled: boolean = true): string { + const url = new URL(DUCKDUCKGO_SEARCH_BASE_URL); + url.searchParams.set("q", query); + if (!aiEnabled) { + url.searchParams.set("ia", "web"); + url.searchParams.set("assist", "false"); + } + return url.toString(); +} + +function mapSuggestionRelevance(index: number): number { + return Math.max(100, 400 - index * 40); +} + +function parseSuggestion(text: string, index: number): QuerySearchProviderCompletion | null { + const completion: QuerySearchProviderCompletion = { + kind: "query", + title: text, + query: text, + relevance: mapSuggestionRelevance(index) + }; + return completion; +} + +function mapDuckDuckGoResponse(response: RawDuckDuckGoResponse): DuckDuckGoSuggestionResponse { + const [query, suggestions] = response; + + return { + query, + suggestions: suggestions.length > 0 ? suggestions.map((phrase) => ({ phrase })) : null + }; +} + +async function fetchDuckDuckGoSuggestions({ + input, + limit, + signal +}: SearchProviderRequest): Promise { + const url = new URL(DUCKDUCKGO_SUGGEST_BASE_URL); + url.searchParams.set("client", "chrome"); + url.searchParams.set("q", input); + url.searchParams.set("type", "list"); + + const response = await fetch(url, { signal }); + if (!response.ok) { + throw new Error(`Failed to fetch DuckDuckGo suggestions: ${response.statusText}`); + } + const data = (await response.json()) as RawDuckDuckGoResponse; + const mappedData = mapDuckDuckGoResponse(data); + + if (!mappedData.suggestions) { + return []; + } + + const completions: QuerySearchProviderCompletion[] = []; + + for (let i = 0; i < Math.min(mappedData.suggestions.length, limit); i++) { + const suggestion = mappedData.suggestions[i]; + const completion = parseSuggestion(suggestion.phrase, i); + if (completion) { + completions.push(completion); + } + } + + return completions; +} + +export const duckduckgoSearchProvider: SearchProvider = { + id: "duckduckgo", + label: "DuckDuckGo", + buildSearchUrl, + async getSuggestions(request: SearchProviderRequest): Promise { + const trimmedInput = request.input.trim(); + if (!trimmedInput) { + return []; + } + + const completions = await fetchDuckDuckGoSuggestions({ ...request, input: trimmedInput }).catch(() => []); + return completions; + } +}; diff --git a/src/renderer/src/lib/omnibox-new/search-providers/google.ts b/src/renderer/src/lib/omnibox-new/search-providers/google.ts index 3ef9e258..43ca2b73 100644 --- a/src/renderer/src/lib/omnibox-new/search-providers/google.ts +++ b/src/renderer/src/lib/omnibox-new/search-providers/google.ts @@ -1,7 +1,6 @@ import type { NavigationSearchProviderCompletion, QuerySearchProviderCompletion, - SearchProviderCompletion, SearchProvider, SearchProviderRequest } from "./types"; @@ -69,7 +68,7 @@ function parseSuggestion( type: GoogleSuggestType | undefined, relevance: number | undefined, index: number -): SearchProviderCompletion | null { +): QuerySearchProviderCompletion | NavigationSearchProviderCompletion | null { if (type === "NAVIGATION") { const url = normalizeAndValidateUrl(text); if (!url) { @@ -99,7 +98,7 @@ async function fetchGoogleSuggestions({ input, limit, signal -}: SearchProviderRequest): Promise { +}: SearchProviderRequest): Promise> { const url = new URL(GOOGLE_SUGGEST_BASE_URL); url.searchParams.set("client", "chrome"); url.searchParams.set("q", input); @@ -115,7 +114,7 @@ async function fetchGoogleSuggestions({ const types = metadata?.["google:suggesttype"] ?? []; const relevances = metadata?.["google:suggestrelevance"] ?? []; - const completions: SearchProviderCompletion[] = []; + const completions: Array = []; for (let index = 0; index < texts.length && completions.length < limit; index += 1) { const text = texts[index]; @@ -136,7 +135,9 @@ export const googleSearchProvider: SearchProvider = { id: "google", label: "Google", buildSearchUrl, - async getSuggestions(request: SearchProviderRequest): Promise { + async getSuggestions( + request: SearchProviderRequest + ): Promise> { const trimmedInput = request.input.trim(); if (!trimmedInput) { return []; diff --git a/src/renderer/src/lib/omnibox-new/search-providers/index.ts b/src/renderer/src/lib/omnibox-new/search-providers/index.ts index e1f69a18..6e50082f 100644 --- a/src/renderer/src/lib/omnibox-new/search-providers/index.ts +++ b/src/renderer/src/lib/omnibox-new/search-providers/index.ts @@ -1,5 +1,14 @@ +import { duckduckgoSearchProvider } from "./duckduckgo"; import { googleSearchProvider } from "./google"; +import type { SearchProvider } from "./types"; -export function getSearchProvider() { - return googleSearchProvider; +export const searchProviders = { + duckduckgo: duckduckgoSearchProvider, + google: googleSearchProvider +} satisfies Record; + +export type SearchProviderId = keyof typeof searchProviders; + +export function getSearchProvider(id: SearchProviderId = "google"): SearchProvider { + return searchProviders[id]; } diff --git a/src/renderer/src/lib/omnibox-new/search-providers/types.ts b/src/renderer/src/lib/omnibox-new/search-providers/types.ts index 6dae78d6..802e6892 100644 --- a/src/renderer/src/lib/omnibox-new/search-providers/types.ts +++ b/src/renderer/src/lib/omnibox-new/search-providers/types.ts @@ -4,30 +4,35 @@ export interface SearchProviderRequest { signal: AbortSignal; } +export type SearchProviderCompletionKind = "query" | "navigation"; + interface SearchProviderCompletionBase { title: string | null; relevance: number; description?: string; isVerbatim?: boolean; providerPayload?: unknown; + kind: SearchProviderCompletionKind; } export interface QuerySearchProviderCompletion extends SearchProviderCompletionBase { - kind: "query"; query: string; + kind: "query"; } export interface NavigationSearchProviderCompletion extends SearchProviderCompletionBase { - kind: "navigation"; url: string; + kind: "navigation"; } + export type SearchProviderCompletion = QuerySearchProviderCompletion | NavigationSearchProviderCompletion; -export interface SearchProvider { +export interface SearchProvider { id: string; label: string; buildSearchUrl(query: string): string; - getSuggestions?(request: SearchProviderRequest): Promise; + getSuggestions?(request: SearchProviderRequest): Promise; } -export type SearchProviderResolver = () => SearchProvider; +export type SearchProviderResolver = + () => SearchProvider; From 781a78f3d2147742b49634f733b98d1a34047ca9 Mon Sep 17 00:00:00 2001 From: JaggedGem Date: Mon, 8 Jun 2026 14:39:29 +0300 Subject: [PATCH 2/9] refactor: change old omnibox to use the same search provider as the new omnibox --- src/renderer/src/lib/search.ts | 40 +++++++++++++++------------------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/src/renderer/src/lib/search.ts b/src/renderer/src/lib/search.ts index c76062f8..38e16f87 100644 --- a/src/renderer/src/lib/search.ts +++ b/src/renderer/src/lib/search.ts @@ -1,30 +1,24 @@ -export function createSearchUrl(query: string) { - return `https://www.google.com/search?q=${encodeURIComponent(query)}`; -} +import { getSearchProvider } from "@/lib/omnibox-new/search-providers"; type SearchSuggestions = string[]; -interface GoogleSuggestResponse { - 0: string; // Original query - 1: string[]; // Suggested queries - 2: string[]; // Description/unused array - 3: unknown[]; // Unknown/unused array - 4: { - // Metadata - "google:clientdata": { - bpc: boolean; - tlw: boolean; - }; - "google:suggestrelevance": number[]; - "google:suggestsubtypes": number[][]; - "google:suggesttype": string[]; - "google:verbatimrelevance": number; - }; +export function createSearchUrl(query: string): string { + return getSearchProvider().buildSearchUrl(query); } export async function getSearchSuggestions(query: string, signal?: AbortSignal): Promise { - const baseURL = `https://suggestqueries.google.com/complete/search?client=chrome&q=${encodeURIComponent(query)}`; - const response = await fetch(baseURL, { signal }); - const data = (await response.json()) as GoogleSuggestResponse; - return data[1]; + const searchProvider = getSearchProvider(); + if (!searchProvider.getSuggestions) { + return []; + } + + const completions = await searchProvider.getSuggestions({ + input: query, + limit: 10, + signal: signal ?? new AbortController().signal + }); + + return completions + .filter((completion) => completion.kind === "query") + .map((completion) => completion.query); } From cc9f90b4cdcb4bc9db3bcaeb862d78137061f2d5 Mon Sep 17 00:00:00 2001 From: JaggedGem Date: Mon, 8 Jun 2026 15:15:40 +0300 Subject: [PATCH 3/9] feat: add settings options for setting the default search engine --- src/main/modules/basic-settings.ts | 26 +++++++++ .../lib/omnibox-new/search-providers/index.ts | 53 ++++++++++++++++++- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/src/main/modules/basic-settings.ts b/src/main/modules/basic-settings.ts index 50ade8fe..c13a66e7 100644 --- a/src/main/modules/basic-settings.ts +++ b/src/main/modules/basic-settings.ts @@ -78,6 +78,25 @@ export const BasicSettings: BasicSetting[] = [ ] }, + // [GENERAL] Search Engine + { + id: "searchEngine", + name: "Search Engine", + showName: true, + type: "enum", + defaultValue: "google", + options: [ + { + id: "google", + name: "Google" + }, + { + id: "duckduckgo", + name: "DuckDuckGo" + } + ] + }, + // New Tab Mode { id: "newTabMode", @@ -269,6 +288,13 @@ export const BasicSettingCards: BasicSettingCard[] = [ settings: ["commandPaletteOpacity"] }, + // Search Engine Card + { + title: "Search Engine", + subtitle: "Choose your default search engine", + settings: ["searchEngine"] + }, + // Sidebar Settings Card { title: "Sidebar Settings", diff --git a/src/renderer/src/lib/omnibox-new/search-providers/index.ts b/src/renderer/src/lib/omnibox-new/search-providers/index.ts index 6e50082f..0da92a08 100644 --- a/src/renderer/src/lib/omnibox-new/search-providers/index.ts +++ b/src/renderer/src/lib/omnibox-new/search-providers/index.ts @@ -9,6 +9,55 @@ export const searchProviders = { export type SearchProviderId = keyof typeof searchProviders; -export function getSearchProvider(id: SearchProviderId = "google"): SearchProvider { - return searchProviders[id]; +const DEFAULT_SEARCH_PROVIDER_ID: SearchProviderId = "google"; + +let selectedSearchProviderId: SearchProviderId = DEFAULT_SEARCH_PROVIDER_ID; +let hasInitializedSearchProviderSetting = false; + +function isSearchProviderId(value: unknown): value is SearchProviderId { + return typeof value === "string" && value in searchProviders; +} + +async function refreshSelectedSearchProvider(): Promise { + if (typeof flow === "undefined") { + return; + } + + const settingValue = await flow.settings.getSetting("searchEngine").catch(() => undefined); + if (isSearchProviderId(settingValue)) { + selectedSearchProviderId = settingValue; + return; + } + + // Backward-compatibility with a previous duplicate setting id. + const legacySettingValue = await flow.settings.getSetting("selectedSearchEngine").catch(() => undefined); + selectedSearchProviderId = isSearchProviderId(legacySettingValue) ? legacySettingValue : DEFAULT_SEARCH_PROVIDER_ID; +} + +function initializeSearchProviderSetting() { + if (hasInitializedSearchProviderSetting || typeof flow === "undefined") { + return; + } + + hasInitializedSearchProviderSetting = true; + void refreshSelectedSearchProvider(); + flow.settings.onSettingsChanged(() => { + void refreshSelectedSearchProvider(); + }); +} + +initializeSearchProviderSetting(); + +/** + * Returns the current search provider based on the user's settings. If no specific provider is requested, it returns the one selected in the settings. + * @param id Optional ID of the search provider to retrieve. If not provided, the function returns the currently selected search provider based on user settings. + * @returns The search provider corresponding to the provided ID or the currently selected search provider if no ID is given. + */ +export function getSearchProvider(id?: SearchProviderId): SearchProvider { + if (id) { + return searchProviders[id]; + } + + initializeSearchProviderSetting(); + return searchProviders[selectedSearchProviderId]; } From d160a6dc94731795de15a2acfc1bd1d5479a9225 Mon Sep 17 00:00:00 2001 From: JaggedGem Date: Mon, 8 Jun 2026 15:44:09 +0300 Subject: [PATCH 4/9] feat: add yandex as a search engine option --- src/main/modules/basic-settings.ts | 4 + .../lib/omnibox-new/search-providers/index.ts | 4 +- .../omnibox-new/search-providers/yandex.ts | 197 ++++++++++++++++++ 3 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/lib/omnibox-new/search-providers/yandex.ts diff --git a/src/main/modules/basic-settings.ts b/src/main/modules/basic-settings.ts index c13a66e7..5d7dd018 100644 --- a/src/main/modules/basic-settings.ts +++ b/src/main/modules/basic-settings.ts @@ -93,6 +93,10 @@ export const BasicSettings: BasicSetting[] = [ { id: "duckduckgo", name: "DuckDuckGo" + }, + { + id: "yandex", + name: "Yandex" } ] }, diff --git a/src/renderer/src/lib/omnibox-new/search-providers/index.ts b/src/renderer/src/lib/omnibox-new/search-providers/index.ts index 0da92a08..cf16a5c7 100644 --- a/src/renderer/src/lib/omnibox-new/search-providers/index.ts +++ b/src/renderer/src/lib/omnibox-new/search-providers/index.ts @@ -1,10 +1,12 @@ import { duckduckgoSearchProvider } from "./duckduckgo"; import { googleSearchProvider } from "./google"; +import { yandexSearchProvider } from "./yandex"; import type { SearchProvider } from "./types"; export const searchProviders = { duckduckgo: duckduckgoSearchProvider, - google: googleSearchProvider + google: googleSearchProvider, + yandex: yandexSearchProvider } satisfies Record; export type SearchProviderId = keyof typeof searchProviders; diff --git a/src/renderer/src/lib/omnibox-new/search-providers/yandex.ts b/src/renderer/src/lib/omnibox-new/search-providers/yandex.ts new file mode 100644 index 00000000..1763b77f --- /dev/null +++ b/src/renderer/src/lib/omnibox-new/search-providers/yandex.ts @@ -0,0 +1,197 @@ +import type { + NavigationSearchProviderCompletion, + QuerySearchProviderCompletion, + SearchProvider, + SearchProviderRequest +} from "./types"; + +type RawYandexSuggestion = + | string + | [kind: string, text: string, description?: string, urlOrHost?: string, ...extra: unknown[]]; + +type RawYandexSuggestResponse = [query?: string, suggestions?: RawYandexSuggestion[], ...extra: unknown[]]; + +interface YandexSuggestion { + phrase: string; + kind: "query" | "navigation"; + url?: string; + description?: string; +} + +const YANDEX_SEARCH_BASE_URL = "https://yandex.com/search/"; +const YANDEX_SUGGEST_BASE_URL = "https://suggest.yandex.com/suggest-ff.cgi"; + +function buildSearchUrl(query: string): string { + const url = new URL(YANDEX_SEARCH_BASE_URL); + url.searchParams.set("text", query); + return url.toString(); +} + +function mapSuggestionRelevance(index: number): number { + return Math.max(100, 400 - index * 40); +} + +function normalizeNavigationUrl(value: string): URL | null { + try { + return new URL(value); + } catch { + try { + return new URL(`https://${value}`); + } catch { + return null; + } + } +} + +const isAllowedProtocol = (url: URL): boolean => ["http:", "https:"].includes(url.protocol.toLowerCase()); + +function normalizeAndValidateUrl(value: string): string | null { + const url = normalizeNavigationUrl(value); + if (!url) { + return null; + } + + if (!isAllowedProtocol(url)) { + return null; + } + + return url.toString(); +} + +function parseRawSuggestion(suggestion: RawYandexSuggestion): YandexSuggestion | null { + if (typeof suggestion === "string") { + return { + kind: "query", + phrase: suggestion + }; + } + + const [kind, text, description, urlOrHost] = suggestion; + const normalizedKind = kind.toLowerCase(); + + if (!text) { + return null; + } + + if (normalizedKind === "nav") { + const rawUrl = urlOrHost ?? text; + const url = normalizeAndValidateUrl(rawUrl); + + if (!url) { + return null; + } + + return { + kind: "navigation", + phrase: text, + url, + description: description ?? url + }; + } + + return { + kind: "query", + phrase: text, + description + }; +} + +function parseSuggestion( + suggestion: YandexSuggestion, + index: number +): QuerySearchProviderCompletion | NavigationSearchProviderCompletion | null { + if (suggestion.kind === "navigation") { + if (!suggestion.url) { + return null; + } + + const completion: NavigationSearchProviderCompletion = { + kind: "navigation", + title: suggestion.phrase, + url: suggestion.url, + description: suggestion.description ?? suggestion.url, + relevance: mapSuggestionRelevance(index) + }; + + return completion; + } + + const completion: QuerySearchProviderCompletion = { + kind: "query", + title: suggestion.phrase, + query: suggestion.phrase, + description: suggestion.description, + relevance: mapSuggestionRelevance(index) + }; + + return completion; +} + +function mapYandexResponse(response: RawYandexSuggestResponse): YandexSuggestion[] { + const [, rawSuggestions = []] = response; + + return rawSuggestions + .map(parseRawSuggestion) + .filter((suggestion): suggestion is YandexSuggestion => suggestion !== null); +} + +async function fetchYandexSuggestions({ + input, + limit, + signal +}: SearchProviderRequest): Promise> { + const url = new URL(YANDEX_SUGGEST_BASE_URL); + url.searchParams.set("part", input); + url.searchParams.set("uil", "en"); + url.searchParams.set("v", "3"); + url.searchParams.set("sn", String(limit)); + + const response = await fetch(url, { signal }); + + if (!response.ok) { + throw new Error(`Failed to fetch Yandex suggestions: ${response.statusText}`); + } + + const data = (await response.json()) as RawYandexSuggestResponse; + const suggestions = mapYandexResponse(data); + + if (suggestions.length === 0) { + return []; + } + + const completions: Array = []; + + for (let index = 0; index < suggestions.length && completions.length < limit; index += 1) { + const suggestion = suggestions[index]; + + if (suggestion.phrase.toLowerCase() === input.toLowerCase()) { + continue; + } + + const completion = parseSuggestion(suggestion, index); + + if (completion) { + completions.push(completion); + } + } + + return completions; +} + +export const yandexSearchProvider: SearchProvider = { + id: "yandex", + label: "Yandex", + buildSearchUrl, + async getSuggestions( + request: SearchProviderRequest + ): Promise> { + const trimmedInput = request.input.trim(); + + if (!trimmedInput) { + return []; + } + + const completions = await fetchYandexSuggestions({ ...request, input: trimmedInput }).catch(() => []); + return completions; + } +}; From f47c7d7e2ecdf18edecd67acfb5e8d249e6598e3 Mon Sep 17 00:00:00 2001 From: JaggedGem Date: Mon, 8 Jun 2026 16:10:28 +0300 Subject: [PATCH 5/9] feat: add search providers to onboarding flow --- .../src/components/onboarding/main.tsx | 10 +- .../onboarding/stages/search-provider.tsx | 167 ++++++++++++++++++ 2 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/components/onboarding/stages/search-provider.tsx diff --git a/src/renderer/src/components/onboarding/main.tsx b/src/renderer/src/components/onboarding/main.tsx index a036ed70..d1d415f5 100644 --- a/src/renderer/src/components/onboarding/main.tsx +++ b/src/renderer/src/components/onboarding/main.tsx @@ -6,10 +6,18 @@ import { OnboardingInitialSpace } from "@/components/onboarding/stages/initial-s import { OnboardingWelcome } from "@/components/onboarding/stages/welcome"; import { AnimatePresence } from "motion/react"; import { useState } from "react"; +import { OnboardingSearchProvider } from "./stages/search-provider"; export type OnboardingAdvanceCallback = () => void; -const stages = [OnboardingWelcome, OnboardingInitialSpace, OnboardingIcon, OnboardingNewTab, OnboardingFinish]; +const stages = [ + OnboardingWelcome, + OnboardingInitialSpace, + OnboardingIcon, + OnboardingNewTab, + OnboardingSearchProvider, + OnboardingFinish +]; export function OnboardingMain() { const [stage, setStage] = useState(0); diff --git a/src/renderer/src/components/onboarding/stages/search-provider.tsx b/src/renderer/src/components/onboarding/stages/search-provider.tsx new file mode 100644 index 00000000..5b1bef81 --- /dev/null +++ b/src/renderer/src/components/onboarding/stages/search-provider.tsx @@ -0,0 +1,167 @@ +import { OnboardingAdvanceCallback } from "@/components/onboarding/main"; +import { WebsiteFavicon } from "@/components/main/website-favicon"; +import type { SearchProviderId } from "@/lib/omnibox-new/search-providers"; +import { motion } from "motion/react"; +import { Button } from "@/components/ui/button"; +import { useSettings } from "@/components/providers/settings-provider"; +import { ArrowRight, SearchCode } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { ReactNode } from "react"; + +type AvailableSearchProviderTile = { + kind: "provider"; + id: SearchProviderId; + name: string; + url: string; + favicon: string; +}; + +type CustomSearchProviderTile = { + kind: "custom"; + id: "custom"; + name: string; + icon: ReactNode; +}; + +type SearchProviderTile = AvailableSearchProviderTile | CustomSearchProviderTile; + +function isSearchProviderId(value: unknown): value is SearchProviderId { + return value === "google" || value === "duckduckgo" || value === "yandex"; +} + +const SEARCH_PROVIDER_TILES: SearchProviderTile[] = [ + { + kind: "provider", + id: "google", + name: "Google", + url: "https://www.google.com", + favicon: "https://www.google.com/favicon.ico" + }, + { + kind: "provider", + id: "duckduckgo", + name: "DuckDuckGo", + url: "https://duckduckgo.com", + favicon: "https://duckduckgo.com/favicon.ico" + }, + { + kind: "provider", + id: "yandex", + name: "Yandex", + url: "https://yandex.com", + favicon: "https://yandex.com/favicon.ico" + }, + { + kind: "custom", + id: "custom", + name: "Custom", + icon: + } +]; + +export function OnboardingSearchProvider({ advance }: { advance: OnboardingAdvanceCallback }) { + const { getSetting, setSetting } = useSettings(); + const selectedProvider = getSetting("searchEngine"); + const selectedProviderId = isSearchProviderId(selectedProvider) ? selectedProvider : null; + + const handleSelectProvider = (providerId: SearchProviderId) => { + if (providerId === selectedProviderId) { + return; + } + + void setSetting("searchEngine", providerId); + }; + + return ( + <> + {/* Header */} + +

Search Provider

+

Choose your preferred search engine

+
+ + {/* Search Provider Tiles */} + +
+ {SEARCH_PROVIDER_TILES.map((provider) => { + const isSelected = provider.kind === "provider" && provider.id === selectedProviderId; + const isAvailable = provider.kind === "provider"; + + return ( + + ); + })} +
+
+ + {/* Continue Button */} +
+ + + +
+ + ); +} From 6a51160be18785eb02e2f591a225536c6fe3ded2 Mon Sep 17 00:00:00 2001 From: JaggedGem Date: Mon, 8 Jun 2026 16:42:13 +0300 Subject: [PATCH 6/9] feat: add support for custom search engines via user-provided URLs with a variable that is replaced at runtime with the user's query - add support to choose either none or any of the available search providers for completion (when custom mode is enabled) - add the custom search engine configuration options to both settings and the onboarding screen --- src/main/modules/basic-settings.ts | 44 ++++- src/main/saving/settings.ts | 3 + .../onboarding/stages/search-provider.tsx | 73 +++++++-- .../search/custom-search-engine-fields.tsx | 153 ++++++++++++++++++ .../sections/general/basic-settings-cards.tsx | 64 +++++++- .../search-providers/custom-utils.ts | 64 ++++++++ .../lib/omnibox-new/search-providers/index.ts | 61 +++++-- src/shared/types/settings.ts | 10 +- 8 files changed, 435 insertions(+), 37 deletions(-) create mode 100644 src/renderer/src/components/search/custom-search-engine-fields.tsx create mode 100644 src/renderer/src/lib/omnibox-new/search-providers/custom-utils.ts diff --git a/src/main/modules/basic-settings.ts b/src/main/modules/basic-settings.ts index 5d7dd018..b076f66f 100644 --- a/src/main/modules/basic-settings.ts +++ b/src/main/modules/basic-settings.ts @@ -83,9 +83,51 @@ export const BasicSettings: BasicSetting[] = [ id: "searchEngine", name: "Search Engine", showName: true, + description: "Pick a built-in engine or switch to a custom URL template.", type: "enum", defaultValue: "google", options: [ + { + id: "google", + name: "Google" + }, + { + id: "duckduckgo", + name: "DuckDuckGo" + }, + { + id: "yandex", + name: "Yandex" + }, + { + id: "custom", + name: "Custom Search Engine" + } + ] + }, + + { + id: "customSearchUrlTemplate", + name: "Search URL Template", + showName: true, + description: "Use {{query}} where Flow should insert the search text.", + type: "string", + defaultValue: "", + placeholder: "https://www.google.com/search?q={{query}}" + }, + + { + id: "customSearchSuggestionsProvider", + name: "Suggestions Source", + showName: true, + description: "Autocomplete can be disabled or powered by a built-in engine.", + type: "enum", + defaultValue: "none", + options: [ + { + id: "none", + name: "None" + }, { id: "google", name: "Google" @@ -296,7 +338,7 @@ export const BasicSettingCards: BasicSettingCard[] = [ { title: "Search Engine", subtitle: "Choose your default search engine", - settings: ["searchEngine"] + settings: ["searchEngine", "customSearchUrlTemplate", "customSearchSuggestionsProvider"] }, // Sidebar Settings Card diff --git a/src/main/saving/settings.ts b/src/main/saving/settings.ts index d6af1ca2..c0060770 100644 --- a/src/main/saving/settings.ts +++ b/src/main/saving/settings.ts @@ -24,6 +24,9 @@ function validateSettingValue(setting: T, value: unknown) if (setting.type === "enum") { return setting.options.some((option) => option.id === value); } + if (setting.type === "string") { + return typeof value === "string"; + } return false; } diff --git a/src/renderer/src/components/onboarding/stages/search-provider.tsx b/src/renderer/src/components/onboarding/stages/search-provider.tsx index 5b1bef81..b50411cb 100644 --- a/src/renderer/src/components/onboarding/stages/search-provider.tsx +++ b/src/renderer/src/components/onboarding/stages/search-provider.tsx @@ -1,6 +1,12 @@ import { OnboardingAdvanceCallback } from "@/components/onboarding/main"; import { WebsiteFavicon } from "@/components/main/website-favicon"; -import type { SearchProviderId } from "@/lib/omnibox-new/search-providers"; +import { CustomSearchEngineFields } from "@/components/search/custom-search-engine-fields"; +import type { + CustomSearchSuggestionsProviderId, + SearchEngineSettingId, + SearchProviderId +} from "@/lib/omnibox-new/search-providers"; +import { validateCustomSearchUrlTemplate } from "@/lib/omnibox-new/search-providers/custom-utils"; import { motion } from "motion/react"; import { Button } from "@/components/ui/button"; import { useSettings } from "@/components/providers/settings-provider"; @@ -29,6 +35,10 @@ function isSearchProviderId(value: unknown): value is SearchProviderId { return value === "google" || value === "duckduckgo" || value === "yandex"; } +function isSearchEngineSettingId(value: unknown): value is SearchEngineSettingId { + return value === "custom" || isSearchProviderId(value); +} + const SEARCH_PROVIDER_TILES: SearchProviderTile[] = [ { kind: "provider", @@ -54,7 +64,7 @@ const SEARCH_PROVIDER_TILES: SearchProviderTile[] = [ { kind: "custom", id: "custom", - name: "Custom", + name: "Custom Search Engine", icon: } ]; @@ -62,9 +72,14 @@ const SEARCH_PROVIDER_TILES: SearchProviderTile[] = [ export function OnboardingSearchProvider({ advance }: { advance: OnboardingAdvanceCallback }) { const { getSetting, setSetting } = useSettings(); const selectedProvider = getSetting("searchEngine"); - const selectedProviderId = isSearchProviderId(selectedProvider) ? selectedProvider : null; + const selectedProviderId = isSearchEngineSettingId(selectedProvider) ? selectedProvider : "google"; + const customSearchUrlTemplate = getSetting("customSearchUrlTemplate") ?? ""; + const customSearchSuggestionsProvider = + (getSetting("customSearchSuggestionsProvider") as CustomSearchSuggestionsProviderId | undefined) ?? "none"; + const customSearchTemplateValidation = validateCustomSearchUrlTemplate(customSearchUrlTemplate); + const canContinue = selectedProviderId !== "custom" || customSearchTemplateValidation.valid; - const handleSelectProvider = (providerId: SearchProviderId) => { + const handleSelectProvider = (providerId: SearchEngineSettingId) => { if (providerId === selectedProviderId) { return; } @@ -96,24 +111,16 @@ export function OnboardingSearchProvider({ advance }: { advance: OnboardingAdvan >
{SEARCH_PROVIDER_TILES.map((provider) => { - const isSelected = provider.kind === "provider" && provider.id === selectedProviderId; - const isAvailable = provider.kind === "provider"; + const isSelected = provider.id === selectedProviderId; return ( ); })}
+ + {selectedProviderId === "custom" && ( + + { + void setSetting("customSearchUrlTemplate", value); + }} + suggestionsProvider={customSearchSuggestionsProvider} + onSuggestionsProviderChange={(value) => { + void setSetting("customSearchSuggestionsProvider", value); + }} + /> + + )} {/* Continue Button */} @@ -155,12 +188,18 @@ export function OnboardingSearchProvider({ advance }: { advance: OnboardingAdvan > + {!canContinue && ( +

+ Enter a valid custom search URL template before continuing. +

+ )} ); diff --git a/src/renderer/src/components/search/custom-search-engine-fields.tsx b/src/renderer/src/components/search/custom-search-engine-fields.tsx new file mode 100644 index 00000000..3c0e210b --- /dev/null +++ b/src/renderer/src/components/search/custom-search-engine-fields.tsx @@ -0,0 +1,153 @@ +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { + CUSTOM_SEARCH_QUERY_TOKEN, + CUSTOM_SEARCH_TEMPLATE_EXAMPLE, + validateCustomSearchUrlTemplate +} from "@/lib/omnibox-new/search-providers/custom-utils"; +import type { CustomSearchSuggestionsProviderId } from "@/lib/omnibox-new/search-providers"; +import { cn } from "@/lib/utils"; +import { AlertCircle, CheckCircle2 } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +const SUGGESTION_SOURCE_OPTIONS: Array<{ id: CustomSearchSuggestionsProviderId; name: string }> = [ + { id: "none", name: "No suggestions" }, + { id: "google", name: "Google suggestions" }, + { id: "duckduckgo", name: "DuckDuckGo suggestions" }, + { id: "yandex", name: "Yandex suggestions" } +]; + +export function CustomSearchEngineFields({ + template, + onTemplateChange, + suggestionsProvider, + onSuggestionsProviderChange, + appearance = "settings" +}: { + template: string; + onTemplateChange: (value: string) => void; + suggestionsProvider: CustomSearchSuggestionsProviderId; + onSuggestionsProviderChange: (value: CustomSearchSuggestionsProviderId) => void; + appearance?: "settings" | "onboarding"; +}) { + const [draftTemplate, setDraftTemplate] = useState(template); + const [isEditingTemplate, setIsEditingTemplate] = useState(false); + const lastCommittedTemplateRef = useRef(template); + const validation = validateCustomSearchUrlTemplate(draftTemplate); + const isOnboarding = appearance === "onboarding"; + + useEffect(() => { + if (!isEditingTemplate && template !== draftTemplate) { + setDraftTemplate(template); + } + lastCommittedTemplateRef.current = template; + }, [draftTemplate, isEditingTemplate, template]); + + useEffect(() => { + if (!isEditingTemplate) { + return; + } + + const timeoutId = window.setTimeout(() => { + if (draftTemplate !== lastCommittedTemplateRef.current) { + onTemplateChange(draftTemplate); + } + }, 350); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [draftTemplate, isEditingTemplate, onTemplateChange]); + + const commitDraftTemplate = () => { + setIsEditingTemplate(false); + if (draftTemplate !== lastCommittedTemplateRef.current) { + onTemplateChange(draftTemplate); + } + }; + + return ( +
+
+ + setDraftTemplate(event.target.value)} + onFocus={() => setIsEditingTemplate(true)} + onBlur={commitDraftTemplate} + placeholder={CUSTOM_SEARCH_TEMPLATE_EXAMPLE} + spellCheck={false} + autoCapitalize="off" + autoCorrect="off" + className={cn( + "font-mono text-sm", + isOnboarding && "border-white/20 bg-white/10 text-white placeholder:text-white/35" + )} + aria-invalid={!validation.valid} + /> +

+ Use {CUSTOM_SEARCH_QUERY_TOKEN} exactly + where the search terms should go. +

+

+ Example: {CUSTOM_SEARCH_TEMPLATE_EXAMPLE} +

+
+ {validation.valid ? ( + + ) : ( + + )} + {validation.valid ? "This template looks good." : validation.reason} +
+
+ +
+ + +

+ This only controls autocomplete suggestions. Your searches still open with your custom URL template. +

+
+
+ ); +} diff --git a/src/renderer/src/components/settings/sections/general/basic-settings-cards.tsx b/src/renderer/src/components/settings/sections/general/basic-settings-cards.tsx index 8c82ab45..6228b853 100644 --- a/src/renderer/src/components/settings/sections/general/basic-settings-cards.tsx +++ b/src/renderer/src/components/settings/sections/general/basic-settings-cards.tsx @@ -1,6 +1,9 @@ import { useSettings } from "@/components/providers/settings-provider"; +import { CustomSearchEngineFields } from "@/components/search/custom-search-engine-fields"; import { BasicSetting, BasicSettingCard } from "~/types/settings"; +import type { CustomSearchSuggestionsProviderId } from "@/lib/omnibox-new/search-providers"; import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { cn } from "@/lib/utils"; @@ -21,7 +24,7 @@ export function SettingsInput({ setting }: { setting: BasicSetting }) { return (
handleSettingChange(event.target.value)} + className="min-w-70" + /> + ); } return null; } export function BasicSettingsCard({ card, transparent }: { card: BasicSettingCard; transparent?: boolean }) { - const { settings } = useSettings(); + const { settings, getSetting, setSetting } = useSettings(); + const selectedSearchEngine = getSetting("searchEngine"); + const isCustomSearchSelected = selectedSearchEngine === "custom"; if (card.title === "INTERNAL_UPDATE") { return ; @@ -64,23 +79,58 @@ export function BasicSettingsCard({ card, transparent }: { card: BasicSettingCar return ; } + if ( + (settingId === "customSearchUrlTemplate" || settingId === "customSearchSuggestionsProvider") && + !isCustomSearchSelected + ) { + return null; + } + const setting = settings.find((s) => s.id === settingId); if (!setting) return null; + const settingDescription = setting.description || null; + + if (settingId === "customSearchUrlTemplate") { + return ( + ("customSearchUrlTemplate") ?? ""} + onTemplateChange={(value) => { + void setSetting("customSearchUrlTemplate", value); + }} + suggestionsProvider={ + (getSetting("customSearchSuggestionsProvider") as + | CustomSearchSuggestionsProviderId + | undefined) ?? "none" + } + onSuggestionsProviderChange={(value) => { + void setSetting("customSearchSuggestionsProvider", value); + }} + /> + ); + } + + if (settingId === "customSearchSuggestionsProvider") { + return null; + } - const settingDescription = (setting as BasicSetting & { description?: string }).description || null; + const isStringSetting = setting.type === "string"; return (
- {setting.showName !== false && settingDescription && ( -

{settingDescription}

- )} + {settingDescription &&

{settingDescription}

}
diff --git a/src/renderer/src/lib/omnibox-new/search-providers/custom-utils.ts b/src/renderer/src/lib/omnibox-new/search-providers/custom-utils.ts new file mode 100644 index 00000000..07a7f428 --- /dev/null +++ b/src/renderer/src/lib/omnibox-new/search-providers/custom-utils.ts @@ -0,0 +1,64 @@ +export const CUSTOM_SEARCH_QUERY_TOKEN = "{{query}}"; +export const CUSTOM_SEARCH_TEMPLATE_EXAMPLE = `https://www.google.com/search?q=${CUSTOM_SEARCH_QUERY_TOKEN}`; + +export type CustomSearchTemplateValidationResult = + | { + valid: true; + } + | { + valid: false; + reason: string; + }; + +export function validateCustomSearchUrlTemplate(template: string): CustomSearchTemplateValidationResult { + const trimmedTemplate = template.trim(); + + if (!trimmedTemplate) { + return { + valid: false, + reason: `Enter a full search URL and include ${CUSTOM_SEARCH_QUERY_TOKEN} where the query should go.` + }; + } + + if (!trimmedTemplate.includes(CUSTOM_SEARCH_QUERY_TOKEN)) { + return { + valid: false, + reason: `Add ${CUSTOM_SEARCH_QUERY_TOKEN} to the URL so Flow knows where to place the search terms.` + }; + } + + const previewUrl = trimmedTemplate.replaceAll(CUSTOM_SEARCH_QUERY_TOKEN, "flow"); + + try { + const parsed = new URL(previewUrl); + if (!["http:", "https:"].includes(parsed.protocol)) { + return { + valid: false, + reason: "Use a full http:// or https:// URL." + }; + } + + if (!parsed.hostname) { + return { + valid: false, + reason: "Use a full URL with a valid domain name." + }; + } + } catch { + return { + valid: false, + reason: "Use a valid full URL, for example https://example.com/search?q={{query}}." + }; + } + + return { valid: true }; +} + +export function buildCustomSearchUrl(template: string, query: string): string | null { + const validation = validateCustomSearchUrlTemplate(template); + if (!validation.valid) { + return null; + } + + return template.trim().replaceAll(CUSTOM_SEARCH_QUERY_TOKEN, encodeURIComponent(query)); +} diff --git a/src/renderer/src/lib/omnibox-new/search-providers/index.ts b/src/renderer/src/lib/omnibox-new/search-providers/index.ts index cf16a5c7..c22bb366 100644 --- a/src/renderer/src/lib/omnibox-new/search-providers/index.ts +++ b/src/renderer/src/lib/omnibox-new/search-providers/index.ts @@ -1,5 +1,6 @@ import { duckduckgoSearchProvider } from "./duckduckgo"; import { googleSearchProvider } from "./google"; +import { buildCustomSearchUrl } from "./custom-utils"; import { yandexSearchProvider } from "./yandex"; import type { SearchProvider } from "./types"; @@ -10,30 +11,66 @@ export const searchProviders = { } satisfies Record; export type SearchProviderId = keyof typeof searchProviders; +export type SearchEngineSettingId = SearchProviderId | "custom"; +export type CustomSearchSuggestionsProviderId = "none" | SearchProviderId; const DEFAULT_SEARCH_PROVIDER_ID: SearchProviderId = "google"; +const DEFAULT_SEARCH_ENGINE_SETTING_ID: SearchEngineSettingId = DEFAULT_SEARCH_PROVIDER_ID; +const DEFAULT_CUSTOM_SEARCH_SUGGESTIONS_PROVIDER_ID: CustomSearchSuggestionsProviderId = "none"; -let selectedSearchProviderId: SearchProviderId = DEFAULT_SEARCH_PROVIDER_ID; +let selectedSearchEngineSettingId: SearchEngineSettingId = DEFAULT_SEARCH_ENGINE_SETTING_ID; +let customSearchUrlTemplate = ""; +let customSearchSuggestionsProviderId: CustomSearchSuggestionsProviderId = + DEFAULT_CUSTOM_SEARCH_SUGGESTIONS_PROVIDER_ID; let hasInitializedSearchProviderSetting = false; -function isSearchProviderId(value: unknown): value is SearchProviderId { +export function isSearchProviderId(value: unknown): value is SearchProviderId { return typeof value === "string" && value in searchProviders; } +export function isSearchEngineSettingId(value: unknown): value is SearchEngineSettingId { + return value === "custom" || isSearchProviderId(value); +} + +export function isCustomSearchSuggestionsProviderId(value: unknown): value is CustomSearchSuggestionsProviderId { + return value === "none" || isSearchProviderId(value); +} + +function createCustomSearchProvider(): SearchProvider { + const suggestionsProvider = + customSearchSuggestionsProviderId !== "none" ? searchProviders[customSearchSuggestionsProviderId] : null; + + return { + id: "custom", + label: "Custom Search Engine", + buildSearchUrl(query: string): string { + return buildCustomSearchUrl(customSearchUrlTemplate, query) ?? searchProviders.google.buildSearchUrl(query); + }, + getSuggestions: suggestionsProvider?.getSuggestions?.bind(suggestionsProvider) + }; +} + async function refreshSelectedSearchProvider(): Promise { if (typeof flow === "undefined") { return; } const settingValue = await flow.settings.getSetting("searchEngine").catch(() => undefined); - if (isSearchProviderId(settingValue)) { - selectedSearchProviderId = settingValue; - return; + if (isSearchEngineSettingId(settingValue)) { + selectedSearchEngineSettingId = settingValue; + } else { + selectedSearchEngineSettingId = DEFAULT_SEARCH_ENGINE_SETTING_ID; } - // Backward-compatibility with a previous duplicate setting id. - const legacySettingValue = await flow.settings.getSetting("selectedSearchEngine").catch(() => undefined); - selectedSearchProviderId = isSearchProviderId(legacySettingValue) ? legacySettingValue : DEFAULT_SEARCH_PROVIDER_ID; + const customTemplateValue = await flow.settings.getSetting("customSearchUrlTemplate").catch(() => undefined); + customSearchUrlTemplate = typeof customTemplateValue === "string" ? customTemplateValue : ""; + + const suggestionsProviderValue = await flow.settings + .getSetting("customSearchSuggestionsProvider") + .catch(() => undefined); + customSearchSuggestionsProviderId = isCustomSearchSuggestionsProviderId(suggestionsProviderValue) + ? suggestionsProviderValue + : DEFAULT_CUSTOM_SEARCH_SUGGESTIONS_PROVIDER_ID; } function initializeSearchProviderSetting() { @@ -55,11 +92,13 @@ initializeSearchProviderSetting(); * @param id Optional ID of the search provider to retrieve. If not provided, the function returns the currently selected search provider based on user settings. * @returns The search provider corresponding to the provided ID or the currently selected search provider if no ID is given. */ -export function getSearchProvider(id?: SearchProviderId): SearchProvider { +export function getSearchProvider(id?: SearchEngineSettingId): SearchProvider { if (id) { - return searchProviders[id]; + return id === "custom" ? createCustomSearchProvider() : searchProviders[id]; } initializeSearchProviderSetting(); - return searchProviders[selectedSearchProviderId]; + return selectedSearchEngineSettingId === "custom" + ? createCustomSearchProvider() + : searchProviders[selectedSearchEngineSettingId]; } diff --git a/src/shared/types/settings.ts b/src/shared/types/settings.ts index 5c06cbe2..3f0498ed 100644 --- a/src/shared/types/settings.ts +++ b/src/shared/types/settings.ts @@ -19,13 +19,21 @@ type SettingTypeEnum = { options: SettingTypeEnumOption[]; }; -export type SettingType = SettingTypeBoolean | SettingTypeEnum; +// Setting Type: String // +type SettingTypeString = { + type: "string"; + defaultValue: string; + placeholder?: string; +}; + +export type SettingType = SettingTypeBoolean | SettingTypeEnum | SettingTypeString; // Setting // export type BasicSetting = { id: string; name: string; showName?: boolean; + description?: string; } & SettingType; // Setting Card // From 5530315946fa480a6989b3c7b4423eccdb4cc344 Mon Sep 17 00:00:00 2001 From: JaggedGem Date: Mon, 8 Jun 2026 17:06:27 +0300 Subject: [PATCH 7/9] feat: enhance search settings management and custom search functionality - Introduced a new search settings snapshot mechanism to manage search engine preferences and custom search URL templates. - Updated context menu to utilize dynamic search engine settings for search actions. - Added IPC handlers for retrieving search settings snapshots asynchronously and synchronously. - Refactored basic settings to include search engine options and custom search URL templates. - Implemented validation for custom search URL templates to ensure they meet required formats. - Enhanced onboarding and settings components to support new search engine configurations. - Updated omnibox providers to build search URLs based on selected search engine settings. - Improved overall search experience by integrating custom search capabilities with existing providers. --- .../tabs-controller/context-menu.ts | 15 +- src/main/ipc/window/settings.ts | 10 +- src/main/modules/basic-settings.ts | 50 ++--- src/main/saving/settings.ts | 63 ++++++- src/preload/index.ts | 6 + .../onboarding/stages/search-provider.tsx | 36 ++-- .../search/custom-search-engine-fields.tsx | 29 ++- .../sections/general/basic-settings-cards.tsx | 25 ++- .../search-providers/custom-utils.ts | 72 +------- .../search-providers/duckduckgo.ts | 12 +- .../omnibox-new/search-providers/google.ts | 6 +- .../lib/omnibox-new/search-providers/index.ts | 94 ++++------ .../omnibox-new/search-providers/yandex.ts | 6 +- src/renderer/src/lib/omnibox-v1.ts | 4 +- src/renderer/src/lib/omnibox-v2.ts | 9 +- src/renderer/src/lib/search.ts | 4 +- .../flow/interfaces/settings/settings.ts | 12 ++ src/shared/search/custom-search.ts | 73 ++++++++ src/shared/search/search-settings.ts | 174 ++++++++++++++++++ 19 files changed, 488 insertions(+), 212 deletions(-) create mode 100644 src/shared/search/custom-search.ts create mode 100644 src/shared/search/search-settings.ts diff --git a/src/main/controllers/tabs-controller/context-menu.ts b/src/main/controllers/tabs-controller/context-menu.ts index 28b2a009..6d0d93f5 100644 --- a/src/main/controllers/tabs-controller/context-menu.ts +++ b/src/main/controllers/tabs-controller/context-menu.ts @@ -4,6 +4,8 @@ import contextMenu from "electron-context-menu"; import { Tab } from "./tab"; import { TabsController } from "./index"; import { saveImageAs } from "./save-image-as"; +import { getSearchSettingsSnapshot } from "@/saving/settings"; +import { buildSearchUrlFromSearchSettings, getSearchEngineDisplayName } from "~/search/search-settings"; // Define types for navigation history interface NavigationHistory { @@ -48,7 +50,8 @@ export function createTabContextMenu( const canGoBack = navigationHistory.canGoBack(); const canGoForward = navigationHistory.canGoForward(); const lookUpSelection = defaultActions.lookUpSelection({}); - const searchEngine = "Google"; + const searchSettings = getSearchSettingsSnapshot(); + const searchEngine = getSearchEngineDisplayName(searchSettings); // Helper function to create a new tab const createNewTab = async (url: string, overrideWindow?: BrowserWindow) => { @@ -72,7 +75,8 @@ export function createTabContextMenu( defaultActions as MenuActions, parameters, createNewTab, - searchEngine + searchEngine, + searchSettings ); const imageItems = createImageItems(parameters, webContents, window, createNewTab, defaultActions as MenuActions); @@ -266,7 +270,8 @@ function createSelectionItems( defaultActions: MenuActions, parameters: Electron.ContextMenuParams, createNewTab: (url: string) => Promise, - searchEngine: string + searchEngine: string, + searchSettings: ReturnType ): Electron.MenuItemConstructorOptions[] { const selectionText = parameters.selectionText; @@ -281,9 +286,7 @@ function createSelectionItems( { label: `Search ${searchEngine} for "${displaySelectionText}"`, click: () => { - const searchURL = new URL("https://www.google.com/search"); - searchURL.searchParams.set("q", selectionText); - createNewTab(searchURL.toString()); + createNewTab(buildSearchUrlFromSearchSettings(searchSettings, selectionText)); } } ]; diff --git a/src/main/ipc/window/settings.ts b/src/main/ipc/window/settings.ts index 869d7c51..ea65eb71 100644 --- a/src/main/ipc/window/settings.ts +++ b/src/main/ipc/window/settings.ts @@ -1,6 +1,6 @@ import { sendMessageToListeners } from "@/ipc/listeners-manager"; import { BasicSettings, BasicSettingCards } from "@/modules/basic-settings"; -import { getSettingValueById, setSettingValueById } from "@/saving/settings"; +import { getSearchSettingsSnapshot, getSettingValueById, setSettingValueById } from "@/saving/settings"; import { settings } from "@/controllers/windows-controller/interfaces/settings"; import { ipcMain } from "electron"; @@ -27,6 +27,14 @@ ipcMain.handle("settings:get-basic-settings", () => { }; }); +ipcMain.handle("settings:get-search-settings-snapshot", () => { + return getSearchSettingsSnapshot(); +}); + +ipcMain.on("settings:get-search-settings-snapshot-sync", (event) => { + event.returnValue = getSearchSettingsSnapshot(); +}); + export function fireOnSettingsChanged() { sendMessageToListeners("settings:on-changed"); } diff --git a/src/main/modules/basic-settings.ts b/src/main/modules/basic-settings.ts index b076f66f..d3c75d96 100644 --- a/src/main/modules/basic-settings.ts +++ b/src/main/modules/basic-settings.ts @@ -2,6 +2,12 @@ // This will make it easier to add new settings and cards. import type { BasicSetting, BasicSettingCard } from "~/types/settings"; +import { + CUSTOM_SEARCH_SUGGESTION_PROVIDER_OPTIONS, + DEFAULT_SEARCH_SETTINGS_SNAPSHOT, + SEARCH_ENGINE_SETTING_OPTIONS +} from "~/search/search-settings"; +import { CUSTOM_SEARCH_TEMPLATE_EXAMPLE } from "~/search/custom-search"; /** * Maps archive tab duration settings to their equivalent values in seconds. @@ -85,25 +91,8 @@ export const BasicSettings: BasicSetting[] = [ showName: true, description: "Pick a built-in engine or switch to a custom URL template.", type: "enum", - defaultValue: "google", - options: [ - { - id: "google", - name: "Google" - }, - { - id: "duckduckgo", - name: "DuckDuckGo" - }, - { - id: "yandex", - name: "Yandex" - }, - { - id: "custom", - name: "Custom Search Engine" - } - ] + defaultValue: DEFAULT_SEARCH_SETTINGS_SNAPSHOT.searchEngine, + options: SEARCH_ENGINE_SETTING_OPTIONS.map((option) => ({ ...option })) }, { @@ -113,7 +102,7 @@ export const BasicSettings: BasicSetting[] = [ description: "Use {{query}} where Flow should insert the search text.", type: "string", defaultValue: "", - placeholder: "https://www.google.com/search?q={{query}}" + placeholder: CUSTOM_SEARCH_TEMPLATE_EXAMPLE }, { @@ -122,25 +111,8 @@ export const BasicSettings: BasicSetting[] = [ showName: true, description: "Autocomplete can be disabled or powered by a built-in engine.", type: "enum", - defaultValue: "none", - options: [ - { - id: "none", - name: "None" - }, - { - id: "google", - name: "Google" - }, - { - id: "duckduckgo", - name: "DuckDuckGo" - }, - { - id: "yandex", - name: "Yandex" - } - ] + defaultValue: DEFAULT_SEARCH_SETTINGS_SNAPSHOT.customSearchSuggestionsProvider, + options: CUSTOM_SEARCH_SUGGESTION_PROVIDER_OPTIONS.map((option) => ({ ...option })) }, // New Tab Mode diff --git a/src/main/saving/settings.ts b/src/main/saving/settings.ts index c0060770..ff836e8b 100644 --- a/src/main/saving/settings.ts +++ b/src/main/saving/settings.ts @@ -3,6 +3,15 @@ import { fireOnSettingsChanged } from "@/ipc/window/settings"; import { BasicSettings } from "@/modules/basic-settings"; import { TypedEventEmitter } from "@/modules/typed-event-emitter"; import { BasicSetting, SettingType } from "~/types/settings"; +import { + type SearchSettingsSnapshot, + getDefaultSearchSettingsSnapshot, + isSearchEngineSettingId, + isCustomSearchSuggestionsProviderId, + isSearchSettingsSnapshotKey, + validateActiveSearchSettings, + DEFAULT_SEARCH_SETTINGS_SNAPSHOT +} from "~/search/search-settings"; export const SettingsDataStore = getDatastore("settings"); @@ -17,6 +26,53 @@ export const settingsEmitter = new TypedEventEmitter(); // Settings: Settings Config // const basicSettingsCurrentValues: Record = {}; +function getSearchSettingsSnapshotFromValues( + values: Partial> +): SearchSettingsSnapshot { + const defaults = getDefaultSearchSettingsSnapshot(); + + return { + searchEngine: isSearchEngineSettingId(values.searchEngine) ? values.searchEngine : defaults.searchEngine, + customSearchUrlTemplate: + typeof values.customSearchUrlTemplate === "string" + ? values.customSearchUrlTemplate + : defaults.customSearchUrlTemplate, + customSearchSuggestionsProvider: isCustomSearchSuggestionsProviderId(values.customSearchSuggestionsProvider) + ? values.customSearchSuggestionsProvider + : defaults.customSearchSuggestionsProvider + }; +} + +function getNextSearchSettingsSnapshot(settingId: string, value: unknown): SearchSettingsSnapshot | null { + if (!isSearchSettingsSnapshotKey(settingId)) { + return null; + } + + return getSearchSettingsSnapshotFromValues({ + ...basicSettingsCurrentValues, + [settingId]: value as SettingType["defaultValue"] + }); +} + +function wouldCreateInvalidActiveSearchConfiguration(settingId: string, value: unknown): boolean { + const nextSearchSettings = getNextSearchSettingsSnapshot(settingId, value); + if (!nextSearchSettings) { + return false; + } + + return !validateActiveSearchSettings(nextSearchSettings).valid; +} + +function repairInvalidActiveSearchConfiguration() { + const searchSettings = getSearchSettingsSnapshotFromValues(basicSettingsCurrentValues); + if (validateActiveSearchSettings(searchSettings).valid) { + return; + } + + basicSettingsCurrentValues.searchEngine = DEFAULT_SEARCH_SETTINGS_SNAPSHOT.searchEngine; + void SettingsDataStore.set("searchEngine", DEFAULT_SEARCH_SETTINGS_SNAPSHOT.searchEngine).catch(() => undefined); +} + function validateSettingValue(setting: T, value: unknown) { if (setting.type === "boolean") { return typeof value === "boolean"; @@ -47,6 +103,7 @@ const settingsCachedPromise = new Promise((resolve) => { } Promise.all(promises).then(() => { + repairInvalidActiveSearchConfiguration(); resolve(); }); }); @@ -58,9 +115,13 @@ export function getSettingValueById(settingId: string): SettingType["defaultValu return basicSettingsCurrentValues[settingId]; } +export function getSearchSettingsSnapshot(): SearchSettingsSnapshot { + return getSearchSettingsSnapshotFromValues(basicSettingsCurrentValues); +} + // Export: Set Setting // async function setSettingValue(setting: T, value: unknown) { - if (validateSettingValue(setting, value)) { + if (validateSettingValue(setting, value) && !wouldCreateInvalidActiveSearchConfiguration(setting.id, value)) { const saveSuccess = await SettingsDataStore.set(setting.id, value) .then(() => true) .catch(() => false); diff --git a/src/preload/index.ts b/src/preload/index.ts index 9569da02..a566f938 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -682,6 +682,12 @@ const settingsAPI: FlowSettingsAPI = { getBasicSettings: async () => { return ipcRenderer.invoke("settings:get-basic-settings"); }, + getSearchSettingsSnapshot: async () => { + return ipcRenderer.invoke("settings:get-search-settings-snapshot"); + }, + getSearchSettingsSnapshotSync: () => { + return ipcRenderer.sendSync("settings:get-search-settings-snapshot-sync"); + }, onSettingsChanged: (callback: () => void) => { return listenOnIPCChannel("settings:on-changed", callback); } diff --git a/src/renderer/src/components/onboarding/stages/search-provider.tsx b/src/renderer/src/components/onboarding/stages/search-provider.tsx index b50411cb..21b39997 100644 --- a/src/renderer/src/components/onboarding/stages/search-provider.tsx +++ b/src/renderer/src/components/onboarding/stages/search-provider.tsx @@ -6,13 +6,18 @@ import type { SearchEngineSettingId, SearchProviderId } from "@/lib/omnibox-new/search-providers"; -import { validateCustomSearchUrlTemplate } from "@/lib/omnibox-new/search-providers/custom-utils"; +import { + getValidCustomSearchUrlTemplateOrDefault, + validateCustomSearchUrlTemplate +} from "@/lib/omnibox-new/search-providers/custom-utils"; import { motion } from "motion/react"; import { Button } from "@/components/ui/button"; import { useSettings } from "@/components/providers/settings-provider"; import { ArrowRight, SearchCode } from "lucide-react"; import { cn } from "@/lib/utils"; -import type { ReactNode } from "react"; +import { useEffect, useState, type ReactNode } from "react"; +import type { CustomSearchTemplateValidationResult } from "~/search/custom-search"; +import { isSearchEngineSettingId } from "~/search/search-settings"; type AvailableSearchProviderTile = { kind: "provider"; @@ -31,14 +36,6 @@ type CustomSearchProviderTile = { type SearchProviderTile = AvailableSearchProviderTile | CustomSearchProviderTile; -function isSearchProviderId(value: unknown): value is SearchProviderId { - return value === "google" || value === "duckduckgo" || value === "yandex"; -} - -function isSearchEngineSettingId(value: unknown): value is SearchEngineSettingId { - return value === "custom" || isSearchProviderId(value); -} - const SEARCH_PROVIDER_TILES: SearchProviderTile[] = [ { kind: "provider", @@ -76,15 +73,29 @@ export function OnboardingSearchProvider({ advance }: { advance: OnboardingAdvan const customSearchUrlTemplate = getSetting("customSearchUrlTemplate") ?? ""; const customSearchSuggestionsProvider = (getSetting("customSearchSuggestionsProvider") as CustomSearchSuggestionsProviderId | undefined) ?? "none"; - const customSearchTemplateValidation = validateCustomSearchUrlTemplate(customSearchUrlTemplate); + const [customSearchTemplateValidation, setCustomSearchTemplateValidation] = + useState(() => validateCustomSearchUrlTemplate(customSearchUrlTemplate)); const canContinue = selectedProviderId !== "custom" || customSearchTemplateValidation.valid; + useEffect(() => { + setCustomSearchTemplateValidation(validateCustomSearchUrlTemplate(customSearchUrlTemplate)); + }, [customSearchUrlTemplate]); + const handleSelectProvider = (providerId: SearchEngineSettingId) => { if (providerId === selectedProviderId) { return; } - void setSetting("searchEngine", providerId); + void (async () => { + if (providerId === "custom") { + const nextTemplate = getValidCustomSearchUrlTemplateOrDefault(customSearchUrlTemplate); + if (nextTemplate !== customSearchUrlTemplate) { + await setSetting("customSearchUrlTemplate", nextTemplate); + } + } + + await setSetting("searchEngine", providerId); + })(); }; return ( @@ -169,6 +180,7 @@ export function OnboardingSearchProvider({ advance }: { advance: OnboardingAdvan onTemplateChange={(value) => { void setSetting("customSearchUrlTemplate", value); }} + onTemplateValidationChange={setCustomSearchTemplateValidation} suggestionsProvider={customSearchSuggestionsProvider} onSuggestionsProviderChange={(value) => { void setSetting("customSearchSuggestionsProvider", value); diff --git a/src/renderer/src/components/search/custom-search-engine-fields.tsx b/src/renderer/src/components/search/custom-search-engine-fields.tsx index 3c0e210b..a86a383b 100644 --- a/src/renderer/src/components/search/custom-search-engine-fields.tsx +++ b/src/renderer/src/components/search/custom-search-engine-fields.tsx @@ -10,6 +10,7 @@ import type { CustomSearchSuggestionsProviderId } from "@/lib/omnibox-new/search import { cn } from "@/lib/utils"; import { AlertCircle, CheckCircle2 } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import type { CustomSearchTemplateValidationResult } from "~/search/custom-search"; const SUGGESTION_SOURCE_OPTIONS: Array<{ id: CustomSearchSuggestionsProviderId; name: string }> = [ { id: "none", name: "No suggestions" }, @@ -23,12 +24,16 @@ export function CustomSearchEngineFields({ onTemplateChange, suggestionsProvider, onSuggestionsProviderChange, + onDraftTemplateChange, + onTemplateValidationChange, appearance = "settings" }: { template: string; onTemplateChange: (value: string) => void; suggestionsProvider: CustomSearchSuggestionsProviderId; onSuggestionsProviderChange: (value: CustomSearchSuggestionsProviderId) => void; + onDraftTemplateChange?: (value: string) => void; + onTemplateValidationChange?: (validation: CustomSearchTemplateValidationResult) => void; appearance?: "settings" | "onboarding"; }) { const [draftTemplate, setDraftTemplate] = useState(template); @@ -38,11 +43,21 @@ export function CustomSearchEngineFields({ const isOnboarding = appearance === "onboarding"; useEffect(() => { - if (!isEditingTemplate && template !== draftTemplate) { - setDraftTemplate(template); + if (template !== lastCommittedTemplateRef.current) { + lastCommittedTemplateRef.current = template; + if (!isEditingTemplate) { + setDraftTemplate(template); + } } - lastCommittedTemplateRef.current = template; - }, [draftTemplate, isEditingTemplate, template]); + }, [isEditingTemplate, template]); + + useEffect(() => { + onDraftTemplateChange?.(draftTemplate); + }, [draftTemplate, onDraftTemplateChange]); + + useEffect(() => { + onTemplateValidationChange?.(validation); + }, [onTemplateValidationChange, validation]); useEffect(() => { if (!isEditingTemplate) { @@ -50,7 +65,7 @@ export function CustomSearchEngineFields({ } const timeoutId = window.setTimeout(() => { - if (draftTemplate !== lastCommittedTemplateRef.current) { + if (validation.valid && draftTemplate !== lastCommittedTemplateRef.current) { onTemplateChange(draftTemplate); } }, 350); @@ -58,11 +73,11 @@ export function CustomSearchEngineFields({ return () => { window.clearTimeout(timeoutId); }; - }, [draftTemplate, isEditingTemplate, onTemplateChange]); + }, [draftTemplate, isEditingTemplate, onTemplateChange, validation.valid]); const commitDraftTemplate = () => { setIsEditingTemplate(false); - if (draftTemplate !== lastCommittedTemplateRef.current) { + if (validation.valid && draftTemplate !== lastCommittedTemplateRef.current) { onTemplateChange(draftTemplate); } }; diff --git a/src/renderer/src/components/settings/sections/general/basic-settings-cards.tsx b/src/renderer/src/components/settings/sections/general/basic-settings-cards.tsx index 6228b853..194e35b7 100644 --- a/src/renderer/src/components/settings/sections/general/basic-settings-cards.tsx +++ b/src/renderer/src/components/settings/sections/general/basic-settings-cards.tsx @@ -1,7 +1,7 @@ import { useSettings } from "@/components/providers/settings-provider"; import { CustomSearchEngineFields } from "@/components/search/custom-search-engine-fields"; import { BasicSetting, BasicSettingCard } from "~/types/settings"; -import type { CustomSearchSuggestionsProviderId } from "@/lib/omnibox-new/search-providers"; +import type { CustomSearchSuggestionsProviderId, SearchEngineSettingId } from "@/lib/omnibox-new/search-providers"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -11,6 +11,7 @@ import { ResetOnboardingCard } from "@/components/settings/sections/general/rese import { UpdateCard } from "@/components/settings/sections/general/update-card"; import { SetAsDefaultBrowserSetting } from "@/components/settings/sections/general/set-as-default-browser-setting"; import { TooltipProvider } from "@/components/ui/tooltip"; +import { getValidCustomSearchUrlTemplateOrDefault } from "@/lib/omnibox-new/search-providers/custom-utils"; export function SettingsInput({ setting }: { setting: BasicSetting }) { const { getSetting, setSetting } = useSettings(); @@ -21,9 +22,29 @@ export function SettingsInput({ setting }: { setting: BasicSetting }) { if (setting.type === "enum") { const settingValue = getSetting(setting.id); + + const handleEnumChange = (value: string) => { + if (setting.id !== "searchEngine") { + void handleSettingChange(value); + return; + } + + void (async () => { + if (value === "custom") { + const currentTemplate = getSetting("customSearchUrlTemplate") ?? ""; + const nextTemplate = getValidCustomSearchUrlTemplateOrDefault(currentTemplate); + if (nextTemplate !== currentTemplate) { + await setSetting("customSearchUrlTemplate", nextTemplate); + } + } + + await setSetting(setting.id, value as SearchEngineSettingId); + })(); + }; + return (
- diff --git a/src/renderer/src/lib/omnibox-new/search-providers/custom-utils.ts b/src/renderer/src/lib/omnibox-new/search-providers/custom-utils.ts index 07a7f428..4a3e8ab5 100644 --- a/src/renderer/src/lib/omnibox-new/search-providers/custom-utils.ts +++ b/src/renderer/src/lib/omnibox-new/search-providers/custom-utils.ts @@ -1,64 +1,8 @@ -export const CUSTOM_SEARCH_QUERY_TOKEN = "{{query}}"; -export const CUSTOM_SEARCH_TEMPLATE_EXAMPLE = `https://www.google.com/search?q=${CUSTOM_SEARCH_QUERY_TOKEN}`; - -export type CustomSearchTemplateValidationResult = - | { - valid: true; - } - | { - valid: false; - reason: string; - }; - -export function validateCustomSearchUrlTemplate(template: string): CustomSearchTemplateValidationResult { - const trimmedTemplate = template.trim(); - - if (!trimmedTemplate) { - return { - valid: false, - reason: `Enter a full search URL and include ${CUSTOM_SEARCH_QUERY_TOKEN} where the query should go.` - }; - } - - if (!trimmedTemplate.includes(CUSTOM_SEARCH_QUERY_TOKEN)) { - return { - valid: false, - reason: `Add ${CUSTOM_SEARCH_QUERY_TOKEN} to the URL so Flow knows where to place the search terms.` - }; - } - - const previewUrl = trimmedTemplate.replaceAll(CUSTOM_SEARCH_QUERY_TOKEN, "flow"); - - try { - const parsed = new URL(previewUrl); - if (!["http:", "https:"].includes(parsed.protocol)) { - return { - valid: false, - reason: "Use a full http:// or https:// URL." - }; - } - - if (!parsed.hostname) { - return { - valid: false, - reason: "Use a full URL with a valid domain name." - }; - } - } catch { - return { - valid: false, - reason: "Use a valid full URL, for example https://example.com/search?q={{query}}." - }; - } - - return { valid: true }; -} - -export function buildCustomSearchUrl(template: string, query: string): string | null { - const validation = validateCustomSearchUrlTemplate(template); - if (!validation.valid) { - return null; - } - - return template.trim().replaceAll(CUSTOM_SEARCH_QUERY_TOKEN, encodeURIComponent(query)); -} +export { + buildCustomSearchUrl, + CUSTOM_SEARCH_QUERY_TOKEN, + CUSTOM_SEARCH_TEMPLATE_EXAMPLE, + getValidCustomSearchUrlTemplateOrDefault, + validateCustomSearchUrlTemplate +} from "~/search/custom-search"; +export type { CustomSearchTemplateValidationResult } from "~/search/custom-search"; diff --git a/src/renderer/src/lib/omnibox-new/search-providers/duckduckgo.ts b/src/renderer/src/lib/omnibox-new/search-providers/duckduckgo.ts index 40681711..6f9af43c 100644 --- a/src/renderer/src/lib/omnibox-new/search-providers/duckduckgo.ts +++ b/src/renderer/src/lib/omnibox-new/search-providers/duckduckgo.ts @@ -1,4 +1,5 @@ import type { QuerySearchProviderCompletion, SearchProvider, SearchProviderRequest } from "./types"; +import { buildSearchUrlFromProviderId } from "~/search/search-settings"; type RawDuckDuckGoResponse = [string, string[]]; @@ -11,17 +12,10 @@ interface DuckDuckGoSuggestionResponse { suggestions: DuckDuckGoSuggestion[] | null; } -const DUCKDUCKGO_SEARCH_BASE_URL = "https://www.duckduckgo.com"; const DUCKDUCKGO_SUGGEST_BASE_URL = "https://duckduckgo.com/ac/"; -function buildSearchUrl(query: string, aiEnabled: boolean = true): string { - const url = new URL(DUCKDUCKGO_SEARCH_BASE_URL); - url.searchParams.set("q", query); - if (!aiEnabled) { - url.searchParams.set("ia", "web"); - url.searchParams.set("assist", "false"); - } - return url.toString(); +function buildSearchUrl(query: string): string { + return buildSearchUrlFromProviderId("duckduckgo", query); } function mapSuggestionRelevance(index: number): number { diff --git a/src/renderer/src/lib/omnibox-new/search-providers/google.ts b/src/renderer/src/lib/omnibox-new/search-providers/google.ts index 43ca2b73..815b0651 100644 --- a/src/renderer/src/lib/omnibox-new/search-providers/google.ts +++ b/src/renderer/src/lib/omnibox-new/search-providers/google.ts @@ -4,6 +4,7 @@ import type { SearchProvider, SearchProviderRequest } from "./types"; +import { buildSearchUrlFromProviderId } from "~/search/search-settings"; interface GoogleSuggestResponse { 0?: string; @@ -19,7 +20,6 @@ interface GoogleSuggestResponse { type GoogleSuggestType = "QUERY" | "NAVIGATION" | "ENTITY" | "TAIL" | "CALCULATOR"; -const GOOGLE_SEARCH_BASE_URL = "https://www.google.com/search"; const GOOGLE_SUGGEST_BASE_URL = "https://suggestqueries.google.com/complete/search"; const SEARCH_SUGGESTION_MIN_RELEVANCE = 100; const SEARCH_SUGGESTION_MAX_RELEVANCE = 400; @@ -58,9 +58,7 @@ function normalizeAndValidateUrl(value: string): string | null { } function buildSearchUrl(query: string): string { - const url = new URL(GOOGLE_SEARCH_BASE_URL); - url.searchParams.set("q", query); - return url.toString(); + return buildSearchUrlFromProviderId("google", query); } function parseSuggestion( diff --git a/src/renderer/src/lib/omnibox-new/search-providers/index.ts b/src/renderer/src/lib/omnibox-new/search-providers/index.ts index c22bb366..a48c4aeb 100644 --- a/src/renderer/src/lib/omnibox-new/search-providers/index.ts +++ b/src/renderer/src/lib/omnibox-new/search-providers/index.ts @@ -3,74 +3,59 @@ import { googleSearchProvider } from "./google"; import { buildCustomSearchUrl } from "./custom-utils"; import { yandexSearchProvider } from "./yandex"; import type { SearchProvider } from "./types"; - -export const searchProviders = { +import { + type CustomSearchSuggestionsProviderId, + type SearchEngineSettingId, + type SearchProviderId, + type SearchSettingsSnapshot, + buildSearchUrlFromProviderId, + getDefaultSearchSettingsSnapshot, + isCustomSearchSuggestionsProviderId, + isSearchEngineSettingId, + isSearchProviderId +} from "~/search/search-settings"; + +export const searchProviders: Record = { duckduckgo: duckduckgoSearchProvider, google: googleSearchProvider, yandex: yandexSearchProvider -} satisfies Record; - -export type SearchProviderId = keyof typeof searchProviders; -export type SearchEngineSettingId = SearchProviderId | "custom"; -export type CustomSearchSuggestionsProviderId = "none" | SearchProviderId; +}; -const DEFAULT_SEARCH_PROVIDER_ID: SearchProviderId = "google"; -const DEFAULT_SEARCH_ENGINE_SETTING_ID: SearchEngineSettingId = DEFAULT_SEARCH_PROVIDER_ID; -const DEFAULT_CUSTOM_SEARCH_SUGGESTIONS_PROVIDER_ID: CustomSearchSuggestionsProviderId = "none"; +export type { CustomSearchSuggestionsProviderId, SearchEngineSettingId, SearchProviderId }; +export { isCustomSearchSuggestionsProviderId, isSearchEngineSettingId, isSearchProviderId }; -let selectedSearchEngineSettingId: SearchEngineSettingId = DEFAULT_SEARCH_ENGINE_SETTING_ID; -let customSearchUrlTemplate = ""; -let customSearchSuggestionsProviderId: CustomSearchSuggestionsProviderId = - DEFAULT_CUSTOM_SEARCH_SUGGESTIONS_PROVIDER_ID; +let currentSearchSettings: SearchSettingsSnapshot = getDefaultSearchSettingsSnapshot(); let hasInitializedSearchProviderSetting = false; -export function isSearchProviderId(value: unknown): value is SearchProviderId { - return typeof value === "string" && value in searchProviders; -} - -export function isSearchEngineSettingId(value: unknown): value is SearchEngineSettingId { - return value === "custom" || isSearchProviderId(value); -} +function readSearchSettingsSnapshot(): SearchSettingsSnapshot { + if (typeof flow === "undefined") { + return getDefaultSearchSettingsSnapshot(); + } -export function isCustomSearchSuggestionsProviderId(value: unknown): value is CustomSearchSuggestionsProviderId { - return value === "none" || isSearchProviderId(value); + return flow.settings.getSearchSettingsSnapshotSync(); } -function createCustomSearchProvider(): SearchProvider { +function createCustomSearchProvider(searchSettings: SearchSettingsSnapshot): SearchProvider { const suggestionsProvider = - customSearchSuggestionsProviderId !== "none" ? searchProviders[customSearchSuggestionsProviderId] : null; + searchSettings.customSearchSuggestionsProvider !== "none" + ? searchProviders[searchSettings.customSearchSuggestionsProvider] + : null; return { id: "custom", label: "Custom Search Engine", buildSearchUrl(query: string): string { - return buildCustomSearchUrl(customSearchUrlTemplate, query) ?? searchProviders.google.buildSearchUrl(query); + return ( + buildCustomSearchUrl(searchSettings.customSearchUrlTemplate, query) ?? + buildSearchUrlFromProviderId("google", query) + ); }, getSuggestions: suggestionsProvider?.getSuggestions?.bind(suggestionsProvider) }; } -async function refreshSelectedSearchProvider(): Promise { - if (typeof flow === "undefined") { - return; - } - - const settingValue = await flow.settings.getSetting("searchEngine").catch(() => undefined); - if (isSearchEngineSettingId(settingValue)) { - selectedSearchEngineSettingId = settingValue; - } else { - selectedSearchEngineSettingId = DEFAULT_SEARCH_ENGINE_SETTING_ID; - } - - const customTemplateValue = await flow.settings.getSetting("customSearchUrlTemplate").catch(() => undefined); - customSearchUrlTemplate = typeof customTemplateValue === "string" ? customTemplateValue : ""; - - const suggestionsProviderValue = await flow.settings - .getSetting("customSearchSuggestionsProvider") - .catch(() => undefined); - customSearchSuggestionsProviderId = isCustomSearchSuggestionsProviderId(suggestionsProviderValue) - ? suggestionsProviderValue - : DEFAULT_CUSTOM_SEARCH_SUGGESTIONS_PROVIDER_ID; +function refreshSelectedSearchProvider() { + currentSearchSettings = readSearchSettingsSnapshot(); } function initializeSearchProviderSetting() { @@ -79,9 +64,9 @@ function initializeSearchProviderSetting() { } hasInitializedSearchProviderSetting = true; - void refreshSelectedSearchProvider(); + refreshSelectedSearchProvider(); flow.settings.onSettingsChanged(() => { - void refreshSelectedSearchProvider(); + refreshSelectedSearchProvider(); }); } @@ -93,12 +78,13 @@ initializeSearchProviderSetting(); * @returns The search provider corresponding to the provided ID or the currently selected search provider if no ID is given. */ export function getSearchProvider(id?: SearchEngineSettingId): SearchProvider { + initializeSearchProviderSetting(); + if (id) { - return id === "custom" ? createCustomSearchProvider() : searchProviders[id]; + return id === "custom" ? createCustomSearchProvider(currentSearchSettings) : searchProviders[id]; } - initializeSearchProviderSetting(); - return selectedSearchEngineSettingId === "custom" - ? createCustomSearchProvider() - : searchProviders[selectedSearchEngineSettingId]; + return currentSearchSettings.searchEngine === "custom" + ? createCustomSearchProvider(currentSearchSettings) + : searchProviders[currentSearchSettings.searchEngine]; } diff --git a/src/renderer/src/lib/omnibox-new/search-providers/yandex.ts b/src/renderer/src/lib/omnibox-new/search-providers/yandex.ts index 1763b77f..d3c04f55 100644 --- a/src/renderer/src/lib/omnibox-new/search-providers/yandex.ts +++ b/src/renderer/src/lib/omnibox-new/search-providers/yandex.ts @@ -4,6 +4,7 @@ import type { SearchProvider, SearchProviderRequest } from "./types"; +import { buildSearchUrlFromProviderId } from "~/search/search-settings"; type RawYandexSuggestion = | string @@ -18,13 +19,10 @@ interface YandexSuggestion { description?: string; } -const YANDEX_SEARCH_BASE_URL = "https://yandex.com/search/"; const YANDEX_SUGGEST_BASE_URL = "https://suggest.yandex.com/suggest-ff.cgi"; function buildSearchUrl(query: string): string { - const url = new URL(YANDEX_SEARCH_BASE_URL); - url.searchParams.set("text", query); - return url.toString(); + return buildSearchUrlFromProviderId("yandex", query); } function mapSuggestionRelevance(index: number): number { diff --git a/src/renderer/src/lib/omnibox-v1.ts b/src/renderer/src/lib/omnibox-v1.ts index 5dbc4539..32f92c94 100644 --- a/src/renderer/src/lib/omnibox-v1.ts +++ b/src/renderer/src/lib/omnibox-v1.ts @@ -112,7 +112,7 @@ export function createVerbatimMatch(input: OmniboxInput): OmniboxMatch { : getURLFromInput(input.text) || "about:blank" : createSearchUrl(input.text), content: input.text, - description: isUrl ? "" : "Search Google for", + description: isUrl ? "" : "Search for", allowedToBeDefault: true, allowInlineAutocompletion: false, type: isUrl ? "verbatim" : "search", @@ -188,7 +188,7 @@ export function createSearchSuggestionMatches(input: OmniboxInput, suggestions: return { destinationUrl: createSearchUrl(suggestion), content: suggestion, - description: "Search Google for", + description: "Search for", allowedToBeDefault: true, // Only allow inline autocompletion if the suggestion starts with the input text // and autocompletion is allowed for this input diff --git a/src/renderer/src/lib/omnibox-v2.ts b/src/renderer/src/lib/omnibox-v2.ts index fc34b55f..bb5361d4 100644 --- a/src/renderer/src/lib/omnibox-v2.ts +++ b/src/renderer/src/lib/omnibox-v2.ts @@ -1,5 +1,7 @@ // THIS IS NOT BEING USED, STORED HERE FOR REFERENCE! +import { createSearchUrl } from "./search"; + // ========= Interfaces and Types ========= /** Represents the input state for an autocomplete query. */ @@ -441,7 +443,6 @@ class ShortcutsProvider extends BaseProvider { class SearchProvider extends BaseProvider { name = "SearchProvider"; - private defaultSearchUrl = "https://www.google.com/search?q="; // Example // Simulate fetching suggestions from Google Suggest API private async fetchSuggestions(query: string): Promise { @@ -481,7 +482,7 @@ class SearchProvider extends BaseProvider { relevance: 1300, // High score to appear near top, but below strong nav contents: inputText, description: `Search for "${inputText}"`, // Or search engine name - destinationUrl: `${this.defaultSearchUrl}${encodeURIComponent(inputText)}`, + destinationUrl: createSearchUrl(inputText), type: "verbatim", // Special type for clarity, often treated as search isDefault: true // Usually the fallback default action }; @@ -501,7 +502,7 @@ class SearchProvider extends BaseProvider { const relevance = 800 - index * 50; // Check if suggestion looks like a URL (navigational suggestion) let type: AutocompleteMatch["type"] = "search-query"; - let destinationUrl = `${this.defaultSearchUrl}${encodeURIComponent(suggestion)}`; + let destinationUrl = createSearchUrl(suggestion); if (suggestion.includes(".") && !suggestion.includes(" ")) { // Basic check for URL-like suggestion type = "search-navigate"; @@ -692,7 +693,7 @@ class AutocompleteController { relevance: 1300, // Default verbatim score contents: input.text, description: `Search for "${input.text}"`, - destinationUrl: `https://www.google.com/search?q=${encodeURIComponent(input.text)}`, // Use default engine + destinationUrl: createSearchUrl(input.text), type: "verbatim", isDefault: true }; diff --git a/src/renderer/src/lib/search.ts b/src/renderer/src/lib/search.ts index 38e16f87..c9324c94 100644 --- a/src/renderer/src/lib/search.ts +++ b/src/renderer/src/lib/search.ts @@ -18,7 +18,5 @@ export async function getSearchSuggestions(query: string, signal?: AbortSignal): signal: signal ?? new AbortController().signal }); - return completions - .filter((completion) => completion.kind === "query") - .map((completion) => completion.query); + return completions.filter((completion) => completion.kind === "query").map((completion) => completion.query); } diff --git a/src/shared/flow/interfaces/settings/settings.ts b/src/shared/flow/interfaces/settings/settings.ts index 5b8681ff..d0cddf68 100644 --- a/src/shared/flow/interfaces/settings/settings.ts +++ b/src/shared/flow/interfaces/settings/settings.ts @@ -1,5 +1,6 @@ import { IPCListener } from "~/flow/types"; import type { BasicSetting, BasicSettingCard } from "~/types/settings"; +import type { SearchSettingsSnapshot } from "~/search/search-settings"; // API // export interface FlowSettingsAPI { @@ -21,6 +22,17 @@ export interface FlowSettingsAPI { cards: BasicSettingCard[]; }>; + /** + * Gets the normalized search settings snapshot. + */ + getSearchSettingsSnapshot: () => Promise; + + /** + * Synchronously gets the current normalized search settings snapshot. + * Used by startup-critical search plumbing that cannot wait for async IPC. + */ + getSearchSettingsSnapshotSync: () => SearchSettingsSnapshot; + /** * Listens for changes to the settings */ onSettingsChanged: IPCListener<[void]>; diff --git a/src/shared/search/custom-search.ts b/src/shared/search/custom-search.ts new file mode 100644 index 00000000..1aa1ddc8 --- /dev/null +++ b/src/shared/search/custom-search.ts @@ -0,0 +1,73 @@ +export const CUSTOM_SEARCH_QUERY_TOKEN = "{{query}}"; +export const CUSTOM_SEARCH_TEMPLATE_EXAMPLE = `https://www.google.com/search?q=${CUSTOM_SEARCH_QUERY_TOKEN}`; + +export type CustomSearchTemplateValidationResult = + | { + valid: true; + } + | { + valid: false; + reason: string; + }; + +/** + * Validates a custom search template and ensures it contains a fully-qualified + * HTTP(S) URL plus the required query token. + */ +export function validateCustomSearchUrlTemplate(template: string): CustomSearchTemplateValidationResult { + const trimmedTemplate = template.trim(); + + if (!trimmedTemplate) { + return { + valid: false, + reason: `Enter a full search URL and include ${CUSTOM_SEARCH_QUERY_TOKEN} where the query should go.` + }; + } + + if (!trimmedTemplate.includes(CUSTOM_SEARCH_QUERY_TOKEN)) { + return { + valid: false, + reason: `Add ${CUSTOM_SEARCH_QUERY_TOKEN} to the URL so Flow knows where to place the search terms.` + }; + } + + const previewUrl = trimmedTemplate.replaceAll(CUSTOM_SEARCH_QUERY_TOKEN, "flow"); + + try { + const parsed = new URL(previewUrl); + if (!["http:", "https:"].includes(parsed.protocol)) { + return { + valid: false, + reason: "Use a full http:// or https:// URL." + }; + } + + if (!parsed.hostname) { + return { + valid: false, + reason: "Use a full URL with a valid domain name." + }; + } + } catch { + return { + valid: false, + reason: "Use a valid full URL, for example https://example.com/search?q={{query}}." + }; + } + + return { valid: true }; +} + +export function buildCustomSearchUrl(template: string, query: string): string | null { + const validation = validateCustomSearchUrlTemplate(template); + if (!validation.valid) { + return null; + } + + return template.trim().replaceAll(CUSTOM_SEARCH_QUERY_TOKEN, encodeURIComponent(query)); +} + +export function getValidCustomSearchUrlTemplateOrDefault(template: string): string { + const validation = validateCustomSearchUrlTemplate(template); + return validation.valid ? template.trim() : CUSTOM_SEARCH_TEMPLATE_EXAMPLE; +} diff --git a/src/shared/search/search-settings.ts b/src/shared/search/search-settings.ts new file mode 100644 index 00000000..bfcdea72 --- /dev/null +++ b/src/shared/search/search-settings.ts @@ -0,0 +1,174 @@ +import { buildCustomSearchUrl, CUSTOM_SEARCH_QUERY_TOKEN, validateCustomSearchUrlTemplate } from "./custom-search"; + +function buildGoogleSearchUrl(query: string): string { + const url = new URL("https://www.google.com/search"); + url.searchParams.set("q", query); + return url.toString(); +} + +function buildDuckDuckGoSearchUrl(query: string, aiEnabled: boolean = true): string { + const url = new URL("https://www.duckduckgo.com"); + url.searchParams.set("q", query); + if (!aiEnabled) { + url.searchParams.set("ia", "web"); + url.searchParams.set("assist", "false"); + } + return url.toString(); +} + +function buildYandexSearchUrl(query: string): string { + const url = new URL("https://yandex.com/search/"); + url.searchParams.set("text", query); + return url.toString(); +} + +const SEARCH_PROVIDER_METADATA = { + google: { + label: "Google", + buildSearchUrl: buildGoogleSearchUrl + }, + duckduckgo: { + label: "DuckDuckGo", + buildSearchUrl: buildDuckDuckGoSearchUrl + }, + yandex: { + label: "Yandex", + buildSearchUrl: buildYandexSearchUrl + } +} as const; + +export type SearchProviderId = keyof typeof SEARCH_PROVIDER_METADATA; +export type SearchEngineSettingId = SearchProviderId | "custom"; +export type CustomSearchSuggestionsProviderId = "none" | SearchProviderId; + +export interface SearchSettingsSnapshot { + searchEngine: SearchEngineSettingId; + customSearchUrlTemplate: string; + customSearchSuggestionsProvider: CustomSearchSuggestionsProviderId; +} + +export type SearchSettingsSnapshotKey = keyof SearchSettingsSnapshot; + +export const DEFAULT_SEARCH_PROVIDER_ID: SearchProviderId = "google"; + +export const DEFAULT_SEARCH_SETTINGS_SNAPSHOT: SearchSettingsSnapshot = { + searchEngine: DEFAULT_SEARCH_PROVIDER_ID, + customSearchUrlTemplate: "", + customSearchSuggestionsProvider: "none" +}; + +export const SEARCH_PROVIDER_OPTIONS: Array<{ id: SearchProviderId; name: string }> = [ + { id: "google", name: SEARCH_PROVIDER_METADATA.google.label }, + { id: "duckduckgo", name: SEARCH_PROVIDER_METADATA.duckduckgo.label }, + { id: "yandex", name: SEARCH_PROVIDER_METADATA.yandex.label } +]; + +export const SEARCH_ENGINE_SETTING_OPTIONS: Array<{ id: SearchEngineSettingId; name: string }> = [ + ...SEARCH_PROVIDER_OPTIONS, + { id: "custom", name: "Custom Search Engine" } +]; + +export const CUSTOM_SEARCH_SUGGESTION_PROVIDER_OPTIONS: Array<{ + id: CustomSearchSuggestionsProviderId; + name: string; +}> = [{ id: "none", name: "None" }, ...SEARCH_PROVIDER_OPTIONS]; + +const SEARCH_SETTINGS_KEYS: SearchSettingsSnapshotKey[] = [ + "searchEngine", + "customSearchUrlTemplate", + "customSearchSuggestionsProvider" +]; + +export function getDefaultSearchSettingsSnapshot(): SearchSettingsSnapshot { + return { + ...DEFAULT_SEARCH_SETTINGS_SNAPSHOT + }; +} + +export function isSearchProviderId(value: unknown): value is SearchProviderId { + return typeof value === "string" && value in SEARCH_PROVIDER_METADATA; +} + +export function isSearchEngineSettingId(value: unknown): value is SearchEngineSettingId { + return value === "custom" || isSearchProviderId(value); +} + +export function isCustomSearchSuggestionsProviderId(value: unknown): value is CustomSearchSuggestionsProviderId { + return value === "none" || isSearchProviderId(value); +} + +export function isSearchSettingsSnapshotKey(value: string): value is SearchSettingsSnapshotKey { + return SEARCH_SETTINGS_KEYS.includes(value as SearchSettingsSnapshotKey); +} + +export function getSearchProviderLabel(id: SearchProviderId): string { + return SEARCH_PROVIDER_METADATA[id].label; +} + +export function buildSearchUrlFromProviderId(providerId: SearchProviderId, query: string): string { + return SEARCH_PROVIDER_METADATA[providerId].buildSearchUrl(query); +} + +export function getCustomSearchEngineDisplayName(template: string): string { + const validation = validateCustomSearchUrlTemplate(template); + if (!validation.valid) { + return "Custom Search Engine"; + } + + try { + const previewUrl = template.trim().replaceAll(CUSTOM_SEARCH_QUERY_TOKEN, "flow"); + const parsed = new URL(previewUrl); + const hostname = parsed.hostname.replace(/^www\./, ""); + return hostname || "Custom Search Engine"; + } catch { + return "Custom Search Engine"; + } +} + +export function getSearchEngineDisplayName(searchSettings: SearchSettingsSnapshot): string { + if (searchSettings.searchEngine === "custom") { + return getCustomSearchEngineDisplayName(searchSettings.customSearchUrlTemplate); + } + + return getSearchProviderLabel(searchSettings.searchEngine); +} + +export function normalizeSearchSettingsSnapshot( + searchSettings: Partial +): SearchSettingsSnapshot { + return { + searchEngine: isSearchEngineSettingId(searchSettings.searchEngine) + ? searchSettings.searchEngine + : DEFAULT_SEARCH_SETTINGS_SNAPSHOT.searchEngine, + customSearchUrlTemplate: + typeof searchSettings.customSearchUrlTemplate === "string" + ? searchSettings.customSearchUrlTemplate + : DEFAULT_SEARCH_SETTINGS_SNAPSHOT.customSearchUrlTemplate, + customSearchSuggestionsProvider: isCustomSearchSuggestionsProviderId(searchSettings.customSearchSuggestionsProvider) + ? searchSettings.customSearchSuggestionsProvider + : DEFAULT_SEARCH_SETTINGS_SNAPSHOT.customSearchSuggestionsProvider + }; +} + +/** + * Validates the active search configuration. Built-in engines are always valid; + * custom engines require a valid template before they can be used. + */ +export function validateActiveSearchSettings(searchSettings: SearchSettingsSnapshot) { + if (searchSettings.searchEngine !== "custom") { + return { valid: true } as const; + } + + return validateCustomSearchUrlTemplate(searchSettings.customSearchUrlTemplate); +} + +export function buildSearchUrlFromSearchSettings(searchSettings: SearchSettingsSnapshot, query: string): string { + if (searchSettings.searchEngine === "custom") { + return ( + buildCustomSearchUrl(searchSettings.customSearchUrlTemplate, query) ?? + buildSearchUrlFromProviderId(DEFAULT_SEARCH_PROVIDER_ID, query) + ); + } + + return buildSearchUrlFromProviderId(searchSettings.searchEngine, query); +} From df0a465eb48b2cd58046f8d5df69c72869926ef8 Mon Sep 17 00:00:00 2001 From: JaggedGem Date: Mon, 8 Jun 2026 17:36:34 +0300 Subject: [PATCH 8/9] fix(search-settings): sync startup repairs and add DuckDuckGo AI preference --- src/main/ipc/window/settings.ts | 5 +-- src/main/modules/basic-settings.ts | 11 +++++- src/main/saving/settings.ts | 26 +++++++++++--- src/preload/index.ts | 3 +- .../onboarding/stages/search-provider.tsx | 20 +++++++++++ .../providers/settings-provider.tsx | 10 +++--- .../search/duckduckgo-ai-toggle.tsx | 32 +++++++++++++++++ .../sections/general/basic-settings-cards.tsx | 18 ++++++++++ .../lib/omnibox-new/search-providers/index.ts | 29 +++++++++++++--- .../flow/interfaces/settings/settings.ts | 7 +++- src/shared/search/search-settings.ts | 34 ++++++++++++++----- 11 files changed, 167 insertions(+), 28 deletions(-) create mode 100644 src/renderer/src/components/search/duckduckgo-ai-toggle.tsx diff --git a/src/main/ipc/window/settings.ts b/src/main/ipc/window/settings.ts index ea65eb71..030a5d93 100644 --- a/src/main/ipc/window/settings.ts +++ b/src/main/ipc/window/settings.ts @@ -3,6 +3,7 @@ import { BasicSettings, BasicSettingCards } from "@/modules/basic-settings"; import { getSearchSettingsSnapshot, getSettingValueById, setSettingValueById } from "@/saving/settings"; import { settings } from "@/controllers/windows-controller/interfaces/settings"; import { ipcMain } from "electron"; +import type { SettingsChangedEvent } from "~/flow/interfaces/settings/settings"; ipcMain.on("settings:open", () => { settings.show(); @@ -35,6 +36,6 @@ ipcMain.on("settings:get-search-settings-snapshot-sync", (event) => { event.returnValue = getSearchSettingsSnapshot(); }); -export function fireOnSettingsChanged() { - sendMessageToListeners("settings:on-changed"); +export function fireOnSettingsChanged(payload: SettingsChangedEvent) { + sendMessageToListeners("settings:on-changed", payload); } diff --git a/src/main/modules/basic-settings.ts b/src/main/modules/basic-settings.ts index d3c75d96..97d65be5 100644 --- a/src/main/modules/basic-settings.ts +++ b/src/main/modules/basic-settings.ts @@ -115,6 +115,15 @@ export const BasicSettings: BasicSetting[] = [ options: CUSTOM_SEARCH_SUGGESTION_PROVIDER_OPTIONS.map((option) => ({ ...option })) }, + { + id: "duckduckgoAiEnabled", + name: "DuckDuckGo AI Features", + showName: true, + description: "Allow DuckDuckGo to open AI-assisted search results instead of forcing classic web results.", + type: "boolean", + defaultValue: DEFAULT_SEARCH_SETTINGS_SNAPSHOT.duckduckgoAiEnabled + }, + // New Tab Mode { id: "newTabMode", @@ -310,7 +319,7 @@ export const BasicSettingCards: BasicSettingCard[] = [ { title: "Search Engine", subtitle: "Choose your default search engine", - settings: ["searchEngine", "customSearchUrlTemplate", "customSearchSuggestionsProvider"] + settings: ["searchEngine", "duckduckgoAiEnabled", "customSearchUrlTemplate", "customSearchSuggestionsProvider"] }, // Sidebar Settings Card diff --git a/src/main/saving/settings.ts b/src/main/saving/settings.ts index ff836e8b..1175b0f9 100644 --- a/src/main/saving/settings.ts +++ b/src/main/saving/settings.ts @@ -12,11 +12,12 @@ import { validateActiveSearchSettings, DEFAULT_SEARCH_SETTINGS_SNAPSHOT } from "~/search/search-settings"; +import type { SettingsChangedEvent } from "~/flow/interfaces/settings/settings"; export const SettingsDataStore = getDatastore("settings"); type SettingsEvents = { - "settings-changed": []; + "settings-changed": [SettingsChangedEvent]; }; export const settingsEmitter = new TypedEventEmitter(); @@ -39,10 +40,27 @@ function getSearchSettingsSnapshotFromValues( : defaults.customSearchUrlTemplate, customSearchSuggestionsProvider: isCustomSearchSuggestionsProviderId(values.customSearchSuggestionsProvider) ? values.customSearchSuggestionsProvider - : defaults.customSearchSuggestionsProvider + : defaults.customSearchSuggestionsProvider, + duckduckgoAiEnabled: + typeof values.duckduckgoAiEnabled === "boolean" ? values.duckduckgoAiEnabled : defaults.duckduckgoAiEnabled }; } +function buildSettingsChangedEvent(changedSettingIds: string[]): SettingsChangedEvent { + const includesSearchSettings = changedSettingIds.some((settingId) => isSearchSettingsSnapshotKey(settingId)); + + return { + changedSettingIds, + searchSettingsSnapshot: includesSearchSettings ? getSearchSettingsSnapshot() : undefined + }; +} + +function notifySettingsChanged(changedSettingIds: string[]) { + const event = buildSettingsChangedEvent(changedSettingIds); + fireOnSettingsChanged(event); + settingsEmitter.emit("settings-changed", event); +} + function getNextSearchSettingsSnapshot(settingId: string, value: unknown): SearchSettingsSnapshot | null { if (!isSearchSettingsSnapshotKey(settingId)) { return null; @@ -70,6 +88,7 @@ function repairInvalidActiveSearchConfiguration() { } basicSettingsCurrentValues.searchEngine = DEFAULT_SEARCH_SETTINGS_SNAPSHOT.searchEngine; + notifySettingsChanged(["searchEngine"]); void SettingsDataStore.set("searchEngine", DEFAULT_SEARCH_SETTINGS_SNAPSHOT.searchEngine).catch(() => undefined); } @@ -128,8 +147,7 @@ async function setSettingValue(setting: T, value: unknow if (saveSuccess) { basicSettingsCurrentValues[setting.id] = value as T["defaultValue"]; - fireOnSettingsChanged(); - settingsEmitter.emit("settings-changed"); + notifySettingsChanged([setting.id]); return true; } } diff --git a/src/preload/index.ts b/src/preload/index.ts index a566f938..62e43577 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -45,6 +45,7 @@ import { FlowPasskeyAPI } from "~/flow/interfaces/browser/passkey"; import type { ConditionalPasskeyRequest, PasskeyCredential } from "~/types/passkey"; import { FlowPromptsAPI } from "~/flow/interfaces/browser/prompts"; import type { ActivePrompt } from "~/types/prompts"; +import type { SettingsChangedEvent } from "~/flow/interfaces/settings/settings"; // const isIFrame = !process.isMainFrame; @@ -688,7 +689,7 @@ const settingsAPI: FlowSettingsAPI = { getSearchSettingsSnapshotSync: () => { return ipcRenderer.sendSync("settings:get-search-settings-snapshot-sync"); }, - onSettingsChanged: (callback: () => void) => { + onSettingsChanged: (callback: (event: SettingsChangedEvent) => void) => { return listenOnIPCChannel("settings:on-changed", callback); } }; diff --git a/src/renderer/src/components/onboarding/stages/search-provider.tsx b/src/renderer/src/components/onboarding/stages/search-provider.tsx index 21b39997..b1ecf9c7 100644 --- a/src/renderer/src/components/onboarding/stages/search-provider.tsx +++ b/src/renderer/src/components/onboarding/stages/search-provider.tsx @@ -1,6 +1,7 @@ import { OnboardingAdvanceCallback } from "@/components/onboarding/main"; import { WebsiteFavicon } from "@/components/main/website-favicon"; import { CustomSearchEngineFields } from "@/components/search/custom-search-engine-fields"; +import { DuckDuckGoAiToggle } from "@/components/search/duckduckgo-ai-toggle"; import type { CustomSearchSuggestionsProviderId, SearchEngineSettingId, @@ -73,6 +74,7 @@ export function OnboardingSearchProvider({ advance }: { advance: OnboardingAdvan const customSearchUrlTemplate = getSetting("customSearchUrlTemplate") ?? ""; const customSearchSuggestionsProvider = (getSetting("customSearchSuggestionsProvider") as CustomSearchSuggestionsProviderId | undefined) ?? "none"; + const duckduckgoAiEnabled = getSetting("duckduckgoAiEnabled") ?? true; const [customSearchTemplateValidation, setCustomSearchTemplateValidation] = useState(() => validateCustomSearchUrlTemplate(customSearchUrlTemplate)); const canContinue = selectedProviderId !== "custom" || customSearchTemplateValidation.valid; @@ -188,6 +190,24 @@ export function OnboardingSearchProvider({ advance }: { advance: OnboardingAdvan /> )} + + {selectedProviderId === "duckduckgo" && ( + + { + void setSetting("duckduckgoAiEnabled", value); + }} + /> + + )} {/* Continue Button */} diff --git a/src/renderer/src/components/providers/settings-provider.tsx b/src/renderer/src/components/providers/settings-provider.tsx index c50fc6aa..cd2ae8c4 100644 --- a/src/renderer/src/components/providers/settings-provider.tsx +++ b/src/renderer/src/components/providers/settings-provider.tsx @@ -53,16 +53,14 @@ export const SettingsProvider = ({ children }: SettingsProviderProps) => { await fetchSettings(); }, [fetchSettings]); - useEffect(() => { - fetchSettings(); - }, [fetchSettings]); - useEffect(() => { const unsub = flow.settings.onSettingsChanged(() => { - revalidate(); + void revalidate(); }); + + void fetchSettings(); return () => unsub(); - }, [revalidate]); + }, [fetchSettings, revalidate]); const getSetting = useCallback( (settingId: string) => { diff --git a/src/renderer/src/components/search/duckduckgo-ai-toggle.tsx b/src/renderer/src/components/search/duckduckgo-ai-toggle.tsx new file mode 100644 index 00000000..16e28377 --- /dev/null +++ b/src/renderer/src/components/search/duckduckgo-ai-toggle.tsx @@ -0,0 +1,32 @@ +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; + +export function DuckDuckGoAiToggle({ + enabled, + onEnabledChange, + appearance = "settings" +}: { + enabled: boolean; + onEnabledChange: (value: boolean) => void; + appearance?: "settings" | "onboarding"; +}) { + const isOnboarding = appearance === "onboarding"; + + return ( +
+
+ +

+ Keep this on to allow DuckDuckGo's AI-assisted experience. Turn it off to force classic web results. +

+
+ +
+ ); +} diff --git a/src/renderer/src/components/settings/sections/general/basic-settings-cards.tsx b/src/renderer/src/components/settings/sections/general/basic-settings-cards.tsx index 194e35b7..80c239ef 100644 --- a/src/renderer/src/components/settings/sections/general/basic-settings-cards.tsx +++ b/src/renderer/src/components/settings/sections/general/basic-settings-cards.tsx @@ -1,5 +1,6 @@ import { useSettings } from "@/components/providers/settings-provider"; import { CustomSearchEngineFields } from "@/components/search/custom-search-engine-fields"; +import { DuckDuckGoAiToggle } from "@/components/search/duckduckgo-ai-toggle"; import { BasicSetting, BasicSettingCard } from "~/types/settings"; import type { CustomSearchSuggestionsProviderId, SearchEngineSettingId } from "@/lib/omnibox-new/search-providers"; import { Label } from "@/components/ui/label"; @@ -80,6 +81,7 @@ export function BasicSettingsCard({ card, transparent }: { card: BasicSettingCar const { settings, getSetting, setSetting } = useSettings(); const selectedSearchEngine = getSetting("searchEngine"); const isCustomSearchSelected = selectedSearchEngine === "custom"; + const isDuckDuckGoSelected = selectedSearchEngine === "duckduckgo"; if (card.title === "INTERNAL_UPDATE") { return ; @@ -107,6 +109,10 @@ export function BasicSettingsCard({ card, transparent }: { card: BasicSettingCar return null; } + if (settingId === "duckduckgoAiEnabled" && !isDuckDuckGoSelected) { + return null; + } + const setting = settings.find((s) => s.id === settingId); if (!setting) return null; const settingDescription = setting.description || null; @@ -135,6 +141,18 @@ export function BasicSettingsCard({ card, transparent }: { card: BasicSettingCar return null; } + if (settingId === "duckduckgoAiEnabled") { + return ( + ("duckduckgoAiEnabled") ?? true} + onEnabledChange={(value) => { + void setSetting("duckduckgoAiEnabled", value); + }} + /> + ); + } + const isStringSetting = setting.type === "string"; return ( diff --git a/src/renderer/src/lib/omnibox-new/search-providers/index.ts b/src/renderer/src/lib/omnibox-new/search-providers/index.ts index a48c4aeb..70b0bde2 100644 --- a/src/renderer/src/lib/omnibox-new/search-providers/index.ts +++ b/src/renderer/src/lib/omnibox-new/search-providers/index.ts @@ -8,6 +8,7 @@ import { type SearchEngineSettingId, type SearchProviderId, type SearchSettingsSnapshot, + buildSearchUrlFromSearchSettings, buildSearchUrlFromProviderId, getDefaultSearchSettingsSnapshot, isCustomSearchSuggestionsProviderId, @@ -54,6 +55,20 @@ function createCustomSearchProvider(searchSettings: SearchSettingsSnapshot): Sea }; } +function createConfiguredBuiltInSearchProvider( + providerId: SearchProviderId, + searchSettings: SearchSettingsSnapshot +): SearchProvider { + const provider = searchProviders[providerId]; + + return { + ...provider, + buildSearchUrl(query: string): string { + return buildSearchUrlFromSearchSettings({ ...searchSettings, searchEngine: providerId }, query); + } + }; +} + function refreshSelectedSearchProvider() { currentSearchSettings = readSearchSettingsSnapshot(); } @@ -64,10 +79,12 @@ function initializeSearchProviderSetting() { } hasInitializedSearchProviderSetting = true; - refreshSelectedSearchProvider(); - flow.settings.onSettingsChanged(() => { - refreshSelectedSearchProvider(); + flow.settings.onSettingsChanged((event) => { + if (event.searchSettingsSnapshot) { + currentSearchSettings = event.searchSettingsSnapshot; + } }); + refreshSelectedSearchProvider(); } initializeSearchProviderSetting(); @@ -81,10 +98,12 @@ export function getSearchProvider(id?: SearchEngineSettingId): SearchProvider { initializeSearchProviderSetting(); if (id) { - return id === "custom" ? createCustomSearchProvider(currentSearchSettings) : searchProviders[id]; + return id === "custom" + ? createCustomSearchProvider(currentSearchSettings) + : createConfiguredBuiltInSearchProvider(id, currentSearchSettings); } return currentSearchSettings.searchEngine === "custom" ? createCustomSearchProvider(currentSearchSettings) - : searchProviders[currentSearchSettings.searchEngine]; + : createConfiguredBuiltInSearchProvider(currentSearchSettings.searchEngine, currentSearchSettings); } diff --git a/src/shared/flow/interfaces/settings/settings.ts b/src/shared/flow/interfaces/settings/settings.ts index d0cddf68..b204ebff 100644 --- a/src/shared/flow/interfaces/settings/settings.ts +++ b/src/shared/flow/interfaces/settings/settings.ts @@ -2,6 +2,11 @@ import { IPCListener } from "~/flow/types"; import type { BasicSetting, BasicSettingCard } from "~/types/settings"; import type { SearchSettingsSnapshot } from "~/search/search-settings"; +export interface SettingsChangedEvent { + changedSettingIds: string[]; + searchSettingsSnapshot?: SearchSettingsSnapshot; +} + // API // export interface FlowSettingsAPI { /** @@ -35,5 +40,5 @@ export interface FlowSettingsAPI { /** * Listens for changes to the settings */ - onSettingsChanged: IPCListener<[void]>; + onSettingsChanged: IPCListener<[SettingsChangedEvent]>; } diff --git a/src/shared/search/search-settings.ts b/src/shared/search/search-settings.ts index bfcdea72..cc961eb9 100644 --- a/src/shared/search/search-settings.ts +++ b/src/shared/search/search-settings.ts @@ -1,14 +1,19 @@ import { buildCustomSearchUrl, CUSTOM_SEARCH_QUERY_TOKEN, validateCustomSearchUrlTemplate } from "./custom-search"; +export interface SearchUrlBuildOptions { + duckduckgoAiEnabled?: boolean; +} + function buildGoogleSearchUrl(query: string): string { const url = new URL("https://www.google.com/search"); url.searchParams.set("q", query); return url.toString(); } -function buildDuckDuckGoSearchUrl(query: string, aiEnabled: boolean = true): string { - const url = new URL("https://www.duckduckgo.com"); +function buildDuckDuckGoSearchUrl(query: string, options?: SearchUrlBuildOptions): string { + const url = new URL("https://duckduckgo.com"); url.searchParams.set("q", query); + const aiEnabled = options?.duckduckgoAiEnabled ?? true; if (!aiEnabled) { url.searchParams.set("ia", "web"); url.searchParams.set("assist", "false"); @@ -45,6 +50,7 @@ export interface SearchSettingsSnapshot { searchEngine: SearchEngineSettingId; customSearchUrlTemplate: string; customSearchSuggestionsProvider: CustomSearchSuggestionsProviderId; + duckduckgoAiEnabled: boolean; } export type SearchSettingsSnapshotKey = keyof SearchSettingsSnapshot; @@ -54,7 +60,8 @@ export const DEFAULT_SEARCH_PROVIDER_ID: SearchProviderId = "google"; export const DEFAULT_SEARCH_SETTINGS_SNAPSHOT: SearchSettingsSnapshot = { searchEngine: DEFAULT_SEARCH_PROVIDER_ID, customSearchUrlTemplate: "", - customSearchSuggestionsProvider: "none" + customSearchSuggestionsProvider: "none", + duckduckgoAiEnabled: true }; export const SEARCH_PROVIDER_OPTIONS: Array<{ id: SearchProviderId; name: string }> = [ @@ -76,7 +83,8 @@ export const CUSTOM_SEARCH_SUGGESTION_PROVIDER_OPTIONS: Array<{ const SEARCH_SETTINGS_KEYS: SearchSettingsSnapshotKey[] = [ "searchEngine", "customSearchUrlTemplate", - "customSearchSuggestionsProvider" + "customSearchSuggestionsProvider", + "duckduckgoAiEnabled" ]; export function getDefaultSearchSettingsSnapshot(): SearchSettingsSnapshot { @@ -105,8 +113,12 @@ export function getSearchProviderLabel(id: SearchProviderId): string { return SEARCH_PROVIDER_METADATA[id].label; } -export function buildSearchUrlFromProviderId(providerId: SearchProviderId, query: string): string { - return SEARCH_PROVIDER_METADATA[providerId].buildSearchUrl(query); +export function buildSearchUrlFromProviderId( + providerId: SearchProviderId, + query: string, + options?: SearchUrlBuildOptions +): string { + return SEARCH_PROVIDER_METADATA[providerId].buildSearchUrl(query, options); } export function getCustomSearchEngineDisplayName(template: string): string { @@ -146,7 +158,11 @@ export function normalizeSearchSettingsSnapshot( : DEFAULT_SEARCH_SETTINGS_SNAPSHOT.customSearchUrlTemplate, customSearchSuggestionsProvider: isCustomSearchSuggestionsProviderId(searchSettings.customSearchSuggestionsProvider) ? searchSettings.customSearchSuggestionsProvider - : DEFAULT_SEARCH_SETTINGS_SNAPSHOT.customSearchSuggestionsProvider + : DEFAULT_SEARCH_SETTINGS_SNAPSHOT.customSearchSuggestionsProvider, + duckduckgoAiEnabled: + typeof searchSettings.duckduckgoAiEnabled === "boolean" + ? searchSettings.duckduckgoAiEnabled + : DEFAULT_SEARCH_SETTINGS_SNAPSHOT.duckduckgoAiEnabled }; } @@ -170,5 +186,7 @@ export function buildSearchUrlFromSearchSettings(searchSettings: SearchSettingsS ); } - return buildSearchUrlFromProviderId(searchSettings.searchEngine, query); + return buildSearchUrlFromProviderId(searchSettings.searchEngine, query, { + duckduckgoAiEnabled: searchSettings.duckduckgoAiEnabled + }); } From 54a22dfa0077fe06d07cd5f01379181e9f6d63f6 Mon Sep 17 00:00:00 2001 From: JaggedGem Date: Mon, 8 Jun 2026 17:40:32 +0300 Subject: [PATCH 9/9] feat: implement suggestion relevance mapping and URL normalization utilities for search providers --- .../src/components/onboarding/main.tsx | 1 + .../search-providers/duckduckgo.ts | 7 +--- .../omnibox-new/search-providers/google.ts | 25 +------------ .../search-providers/suggestion-utils.ts | 3 ++ .../lib/omnibox-new/search-providers/types.ts | 2 +- .../omnibox-new/search-providers/url-utils.ts | 28 ++++++++++++++ .../omnibox-new/search-providers/yandex.ts | 37 ++----------------- src/renderer/src/lib/search.ts | 2 +- 8 files changed, 41 insertions(+), 64 deletions(-) create mode 100644 src/renderer/src/lib/omnibox-new/search-providers/suggestion-utils.ts create mode 100644 src/renderer/src/lib/omnibox-new/search-providers/url-utils.ts diff --git a/src/renderer/src/components/onboarding/main.tsx b/src/renderer/src/components/onboarding/main.tsx index d1d415f5..05030381 100644 --- a/src/renderer/src/components/onboarding/main.tsx +++ b/src/renderer/src/components/onboarding/main.tsx @@ -29,6 +29,7 @@ export function OnboardingMain() { const Stage = stages[stage]; if (!Stage) { flow.onboarding.finish(); + return null; } return ( diff --git a/src/renderer/src/lib/omnibox-new/search-providers/duckduckgo.ts b/src/renderer/src/lib/omnibox-new/search-providers/duckduckgo.ts index 6f9af43c..d845452c 100644 --- a/src/renderer/src/lib/omnibox-new/search-providers/duckduckgo.ts +++ b/src/renderer/src/lib/omnibox-new/search-providers/duckduckgo.ts @@ -1,4 +1,5 @@ import type { QuerySearchProviderCompletion, SearchProvider, SearchProviderRequest } from "./types"; +import { mapSuggestionRelevanceByIndex } from "./suggestion-utils"; import { buildSearchUrlFromProviderId } from "~/search/search-settings"; type RawDuckDuckGoResponse = [string, string[]]; @@ -18,16 +19,12 @@ function buildSearchUrl(query: string): string { return buildSearchUrlFromProviderId("duckduckgo", query); } -function mapSuggestionRelevance(index: number): number { - return Math.max(100, 400 - index * 40); -} - function parseSuggestion(text: string, index: number): QuerySearchProviderCompletion | null { const completion: QuerySearchProviderCompletion = { kind: "query", title: text, query: text, - relevance: mapSuggestionRelevance(index) + relevance: mapSuggestionRelevanceByIndex(index) }; return completion; } diff --git a/src/renderer/src/lib/omnibox-new/search-providers/google.ts b/src/renderer/src/lib/omnibox-new/search-providers/google.ts index 815b0651..4a79954c 100644 --- a/src/renderer/src/lib/omnibox-new/search-providers/google.ts +++ b/src/renderer/src/lib/omnibox-new/search-providers/google.ts @@ -4,6 +4,7 @@ import type { SearchProvider, SearchProviderRequest } from "./types"; +import { normalizeAndValidateUrl } from "./url-utils"; import { buildSearchUrlFromProviderId } from "~/search/search-settings"; interface GoogleSuggestResponse { @@ -33,30 +34,6 @@ function mapSuggestionRelevance(serverRelevance: number | undefined, index: numb ); } -function normalizeNavigationUrl(value: string): URL | null { - try { - return new URL(value); - } catch { - try { - return new URL(`http://${value}`); - } catch { - return null; - } - } -} - -const isAllowedProtocol = (url: URL): boolean => ["http:", "https:"].includes(url.protocol.toLowerCase()); -function normalizeAndValidateUrl(value: string): string | null { - const url = normalizeNavigationUrl(value); - if (!url) { - return null; - } - if (!isAllowedProtocol(url)) { - return null; - } - return url.toString(); -} - function buildSearchUrl(query: string): string { return buildSearchUrlFromProviderId("google", query); } diff --git a/src/renderer/src/lib/omnibox-new/search-providers/suggestion-utils.ts b/src/renderer/src/lib/omnibox-new/search-providers/suggestion-utils.ts new file mode 100644 index 00000000..2999ef07 --- /dev/null +++ b/src/renderer/src/lib/omnibox-new/search-providers/suggestion-utils.ts @@ -0,0 +1,3 @@ +export function mapSuggestionRelevanceByIndex(index: number): number { + return Math.max(100, 400 - index * 40); +} diff --git a/src/renderer/src/lib/omnibox-new/search-providers/types.ts b/src/renderer/src/lib/omnibox-new/search-providers/types.ts index 802e6892..51f50c49 100644 --- a/src/renderer/src/lib/omnibox-new/search-providers/types.ts +++ b/src/renderer/src/lib/omnibox-new/search-providers/types.ts @@ -1,7 +1,7 @@ export interface SearchProviderRequest { input: string; limit: number; - signal: AbortSignal; + signal?: AbortSignal; } export type SearchProviderCompletionKind = "query" | "navigation"; diff --git a/src/renderer/src/lib/omnibox-new/search-providers/url-utils.ts b/src/renderer/src/lib/omnibox-new/search-providers/url-utils.ts new file mode 100644 index 00000000..08f6366d --- /dev/null +++ b/src/renderer/src/lib/omnibox-new/search-providers/url-utils.ts @@ -0,0 +1,28 @@ +export function normalizeNavigationUrl(value: string): URL | null { + try { + return new URL(value); + } catch { + try { + return new URL(`https://${value}`); + } catch { + return null; + } + } +} + +export function isAllowedProtocol(url: URL): boolean { + return ["http:", "https:"].includes(url.protocol.toLowerCase()); +} + +export function normalizeAndValidateUrl(value: string): string | null { + const url = normalizeNavigationUrl(value); + if (!url) { + return null; + } + + if (!isAllowedProtocol(url)) { + return null; + } + + return url.toString(); +} diff --git a/src/renderer/src/lib/omnibox-new/search-providers/yandex.ts b/src/renderer/src/lib/omnibox-new/search-providers/yandex.ts index d3c04f55..8ca6b148 100644 --- a/src/renderer/src/lib/omnibox-new/search-providers/yandex.ts +++ b/src/renderer/src/lib/omnibox-new/search-providers/yandex.ts @@ -4,6 +4,8 @@ import type { SearchProvider, SearchProviderRequest } from "./types"; +import { mapSuggestionRelevanceByIndex } from "./suggestion-utils"; +import { normalizeAndValidateUrl } from "./url-utils"; import { buildSearchUrlFromProviderId } from "~/search/search-settings"; type RawYandexSuggestion = @@ -25,37 +27,6 @@ function buildSearchUrl(query: string): string { return buildSearchUrlFromProviderId("yandex", query); } -function mapSuggestionRelevance(index: number): number { - return Math.max(100, 400 - index * 40); -} - -function normalizeNavigationUrl(value: string): URL | null { - try { - return new URL(value); - } catch { - try { - return new URL(`https://${value}`); - } catch { - return null; - } - } -} - -const isAllowedProtocol = (url: URL): boolean => ["http:", "https:"].includes(url.protocol.toLowerCase()); - -function normalizeAndValidateUrl(value: string): string | null { - const url = normalizeNavigationUrl(value); - if (!url) { - return null; - } - - if (!isAllowedProtocol(url)) { - return null; - } - - return url.toString(); -} - function parseRawSuggestion(suggestion: RawYandexSuggestion): YandexSuggestion | null { if (typeof suggestion === "string") { return { @@ -108,7 +79,7 @@ function parseSuggestion( title: suggestion.phrase, url: suggestion.url, description: suggestion.description ?? suggestion.url, - relevance: mapSuggestionRelevance(index) + relevance: mapSuggestionRelevanceByIndex(index) }; return completion; @@ -119,7 +90,7 @@ function parseSuggestion( title: suggestion.phrase, query: suggestion.phrase, description: suggestion.description, - relevance: mapSuggestionRelevance(index) + relevance: mapSuggestionRelevanceByIndex(index) }; return completion; diff --git a/src/renderer/src/lib/search.ts b/src/renderer/src/lib/search.ts index c9324c94..ce00a94f 100644 --- a/src/renderer/src/lib/search.ts +++ b/src/renderer/src/lib/search.ts @@ -15,7 +15,7 @@ export async function getSearchSuggestions(query: string, signal?: AbortSignal): const completions = await searchProvider.getSuggestions({ input: query, limit: 10, - signal: signal ?? new AbortController().signal + signal }); return completions.filter((completion) => completion.kind === "query").map((completion) => completion.query);