diff --git a/package.json b/package.json index c0eb8c4e7..017de34c9 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/tabs-controller/context-menu.ts b/src/main/controllers/tabs-controller/context-menu.ts index 28b2a009f..6d0d93f5d 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/controllers/windows-controller/utils/browser/omnibox.ts b/src/main/controllers/windows-controller/utils/browser/omnibox.ts index ae9b16845..081784280 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/main/ipc/window/settings.ts b/src/main/ipc/window/settings.ts index 869d7c513..030a5d932 100644 --- a/src/main/ipc/window/settings.ts +++ b/src/main/ipc/window/settings.ts @@ -1,8 +1,9 @@ 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"; +import type { SettingsChangedEvent } from "~/flow/interfaces/settings/settings"; ipcMain.on("settings:open", () => { settings.show(); @@ -27,6 +28,14 @@ ipcMain.handle("settings:get-basic-settings", () => { }; }); -export function fireOnSettingsChanged() { - sendMessageToListeners("settings:on-changed"); +ipcMain.handle("settings:get-search-settings-snapshot", () => { + return getSearchSettingsSnapshot(); +}); + +ipcMain.on("settings:get-search-settings-snapshot-sync", (event) => { + event.returnValue = getSearchSettingsSnapshot(); +}); + +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 50ade8fee..97d65be56 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. @@ -78,6 +84,46 @@ export const BasicSettings: BasicSetting[] = [ ] }, + // [GENERAL] Search Engine + { + id: "searchEngine", + name: "Search Engine", + showName: true, + description: "Pick a built-in engine or switch to a custom URL template.", + type: "enum", + defaultValue: DEFAULT_SEARCH_SETTINGS_SNAPSHOT.searchEngine, + options: SEARCH_ENGINE_SETTING_OPTIONS.map((option) => ({ ...option })) + }, + + { + id: "customSearchUrlTemplate", + name: "Search URL Template", + showName: true, + description: "Use {{query}} where Flow should insert the search text.", + type: "string", + defaultValue: "", + placeholder: CUSTOM_SEARCH_TEMPLATE_EXAMPLE + }, + + { + id: "customSearchSuggestionsProvider", + name: "Suggestions Source", + showName: true, + description: "Autocomplete can be disabled or powered by a built-in engine.", + type: "enum", + defaultValue: DEFAULT_SEARCH_SETTINGS_SNAPSHOT.customSearchSuggestionsProvider, + 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", @@ -269,6 +315,13 @@ export const BasicSettingCards: BasicSettingCard[] = [ settings: ["commandPaletteOpacity"] }, + // Search Engine Card + { + title: "Search Engine", + subtitle: "Choose your default search engine", + settings: ["searchEngine", "duckduckgoAiEnabled", "customSearchUrlTemplate", "customSearchSuggestionsProvider"] + }, + // Sidebar Settings Card { title: "Sidebar Settings", diff --git a/src/main/saving/settings.ts b/src/main/saving/settings.ts index d6af1ca2f..1175b0f91 100644 --- a/src/main/saving/settings.ts +++ b/src/main/saving/settings.ts @@ -3,11 +3,21 @@ 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"; +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(); @@ -17,6 +27,71 @@ 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, + 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; + } + + 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; + notifySettingsChanged(["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"; @@ -24,6 +99,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; } @@ -44,6 +122,7 @@ const settingsCachedPromise = new Promise((resolve) => { } Promise.all(promises).then(() => { + repairInvalidActiveSearchConfiguration(); resolve(); }); }); @@ -55,17 +134,20 @@ 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); 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 9569da02b..62e435778 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; @@ -682,7 +683,13 @@ const settingsAPI: FlowSettingsAPI = { getBasicSettings: async () => { return ipcRenderer.invoke("settings:get-basic-settings"); }, - onSettingsChanged: (callback: () => void) => { + getSearchSettingsSnapshot: async () => { + return ipcRenderer.invoke("settings:get-search-settings-snapshot"); + }, + getSearchSettingsSnapshotSync: () => { + return ipcRenderer.sendSync("settings:get-search-settings-snapshot-sync"); + }, + onSettingsChanged: (callback: (event: SettingsChangedEvent) => void) => { return listenOnIPCChannel("settings:on-changed", callback); } }; diff --git a/src/renderer/src/components/onboarding/main.tsx b/src/renderer/src/components/onboarding/main.tsx index a036ed708..050303814 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); @@ -21,6 +29,7 @@ export function OnboardingMain() { const Stage = stages[stage]; if (!Stage) { flow.onboarding.finish(); + return null; } return ( 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 000000000..b1ecf9c75 --- /dev/null +++ b/src/renderer/src/components/onboarding/stages/search-provider.tsx @@ -0,0 +1,238 @@ +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, + SearchProviderId +} from "@/lib/omnibox-new/search-providers"; +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 { useEffect, useState, type ReactNode } from "react"; +import type { CustomSearchTemplateValidationResult } from "~/search/custom-search"; +import { isSearchEngineSettingId } from "~/search/search-settings"; + +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; + +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 Search Engine", + icon: + } +]; + +export function OnboardingSearchProvider({ advance }: { advance: OnboardingAdvanceCallback }) { + const { getSetting, setSetting } = useSettings(); + const selectedProvider = getSetting("searchEngine"); + const selectedProviderId = isSearchEngineSettingId(selectedProvider) ? selectedProvider : "google"; + 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; + + useEffect(() => { + setCustomSearchTemplateValidation(validateCustomSearchUrlTemplate(customSearchUrlTemplate)); + }, [customSearchUrlTemplate]); + + const handleSelectProvider = (providerId: SearchEngineSettingId) => { + if (providerId === selectedProviderId) { + return; + } + + void (async () => { + if (providerId === "custom") { + const nextTemplate = getValidCustomSearchUrlTemplateOrDefault(customSearchUrlTemplate); + if (nextTemplate !== customSearchUrlTemplate) { + await setSetting("customSearchUrlTemplate", nextTemplate); + } + } + + await setSetting("searchEngine", providerId); + })(); + }; + + return ( + <> + {/* Header */} + +

Search Provider

+

Choose your preferred search engine

+
+ + {/* Search Provider Tiles */} + +
+ {SEARCH_PROVIDER_TILES.map((provider) => { + const isSelected = provider.id === selectedProviderId; + + return ( + + ); + })} +
+ + {selectedProviderId === "custom" && ( + + { + void setSetting("customSearchUrlTemplate", value); + }} + onTemplateValidationChange={setCustomSearchTemplateValidation} + suggestionsProvider={customSearchSuggestionsProvider} + onSuggestionsProviderChange={(value) => { + void setSetting("customSearchSuggestionsProvider", value); + }} + /> + + )} + + {selectedProviderId === "duckduckgo" && ( + + { + void setSetting("duckduckgoAiEnabled", value); + }} + /> + + )} +
+ + {/* Continue Button */} +
+ + + + {!canContinue && ( +

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

+ )} +
+ + ); +} diff --git a/src/renderer/src/components/providers/settings-provider.tsx b/src/renderer/src/components/providers/settings-provider.tsx index c50fc6aad..cd2ae8c40 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/custom-search-engine-fields.tsx b/src/renderer/src/components/search/custom-search-engine-fields.tsx new file mode 100644 index 000000000..a86a383bb --- /dev/null +++ b/src/renderer/src/components/search/custom-search-engine-fields.tsx @@ -0,0 +1,168 @@ +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"; +import type { CustomSearchTemplateValidationResult } from "~/search/custom-search"; + +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, + 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); + const [isEditingTemplate, setIsEditingTemplate] = useState(false); + const lastCommittedTemplateRef = useRef(template); + const validation = validateCustomSearchUrlTemplate(draftTemplate); + const isOnboarding = appearance === "onboarding"; + + useEffect(() => { + if (template !== lastCommittedTemplateRef.current) { + lastCommittedTemplateRef.current = template; + if (!isEditingTemplate) { + setDraftTemplate(template); + } + } + }, [isEditingTemplate, template]); + + useEffect(() => { + onDraftTemplateChange?.(draftTemplate); + }, [draftTemplate, onDraftTemplateChange]); + + useEffect(() => { + onTemplateValidationChange?.(validation); + }, [onTemplateValidationChange, validation]); + + useEffect(() => { + if (!isEditingTemplate) { + return; + } + + const timeoutId = window.setTimeout(() => { + if (validation.valid && draftTemplate !== lastCommittedTemplateRef.current) { + onTemplateChange(draftTemplate); + } + }, 350); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [draftTemplate, isEditingTemplate, onTemplateChange, validation.valid]); + + const commitDraftTemplate = () => { + setIsEditingTemplate(false); + if (validation.valid && 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/search/duckduckgo-ai-toggle.tsx b/src/renderer/src/components/search/duckduckgo-ai-toggle.tsx new file mode 100644 index 000000000..16e283775 --- /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 8c82ab45f..80c239efb 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,10 @@ 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"; +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"; @@ -8,6 +12,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(); @@ -18,10 +23,30 @@ 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 (
- + @@ -37,13 +62,26 @@ export function SettingsInput({ setting }: { setting: BasicSetting }) { } else if (setting.type === "boolean") { const settingValue = getSetting(setting.id); return ; + } else if (setting.type === "string") { + const settingValue = getSetting(setting.id); + 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"; + const isDuckDuckGoSelected = selectedSearchEngine === "duckduckgo"; if (card.title === "INTERNAL_UPDATE") { return ; @@ -64,23 +102,74 @@ export function BasicSettingsCard({ card, transparent }: { card: BasicSettingCar return ; } + if ( + (settingId === "customSearchUrlTemplate" || settingId === "customSearchSuggestionsProvider") && + !isCustomSearchSelected + ) { + 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; + + 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; + } + + if (settingId === "duckduckgoAiEnabled") { + return ( + ("duckduckgoAiEnabled") ?? true} + onEnabledChange={(value) => { + void setSetting("duckduckgoAiEnabled", value); + }} + /> + ); + } - 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 000000000..4a3e8ab5c --- /dev/null +++ b/src/renderer/src/lib/omnibox-new/search-providers/custom-utils.ts @@ -0,0 +1,8 @@ +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 new file mode 100644 index 000000000..d845452cc --- /dev/null +++ b/src/renderer/src/lib/omnibox-new/search-providers/duckduckgo.ts @@ -0,0 +1,88 @@ +import type { QuerySearchProviderCompletion, SearchProvider, SearchProviderRequest } from "./types"; +import { mapSuggestionRelevanceByIndex } from "./suggestion-utils"; +import { buildSearchUrlFromProviderId } from "~/search/search-settings"; + +type RawDuckDuckGoResponse = [string, string[]]; + +interface DuckDuckGoSuggestion { + phrase: string; +} + +interface DuckDuckGoSuggestionResponse { + query: string; + suggestions: DuckDuckGoSuggestion[] | null; +} + +const DUCKDUCKGO_SUGGEST_BASE_URL = "https://duckduckgo.com/ac/"; + +function buildSearchUrl(query: string): string { + return buildSearchUrlFromProviderId("duckduckgo", query); +} + +function parseSuggestion(text: string, index: number): QuerySearchProviderCompletion | null { + const completion: QuerySearchProviderCompletion = { + kind: "query", + title: text, + query: text, + relevance: mapSuggestionRelevanceByIndex(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 3ef9e2581..4a79954c6 100644 --- a/src/renderer/src/lib/omnibox-new/search-providers/google.ts +++ b/src/renderer/src/lib/omnibox-new/search-providers/google.ts @@ -1,10 +1,11 @@ import type { NavigationSearchProviderCompletion, QuerySearchProviderCompletion, - SearchProviderCompletion, SearchProvider, SearchProviderRequest } from "./types"; +import { normalizeAndValidateUrl } from "./url-utils"; +import { buildSearchUrlFromProviderId } from "~/search/search-settings"; interface GoogleSuggestResponse { 0?: string; @@ -20,7 +21,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; @@ -34,34 +34,8 @@ 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 { - const url = new URL(GOOGLE_SEARCH_BASE_URL); - url.searchParams.set("q", query); - return url.toString(); + return buildSearchUrlFromProviderId("google", query); } function parseSuggestion( @@ -69,7 +43,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 +73,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 +89,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 +110,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 e1f69a185..70b0bde2c 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,109 @@ +import { duckduckgoSearchProvider } from "./duckduckgo"; import { googleSearchProvider } from "./google"; +import { buildCustomSearchUrl } from "./custom-utils"; +import { yandexSearchProvider } from "./yandex"; +import type { SearchProvider } from "./types"; +import { + type CustomSearchSuggestionsProviderId, + type SearchEngineSettingId, + type SearchProviderId, + type SearchSettingsSnapshot, + buildSearchUrlFromSearchSettings, + buildSearchUrlFromProviderId, + getDefaultSearchSettingsSnapshot, + isCustomSearchSuggestionsProviderId, + isSearchEngineSettingId, + isSearchProviderId +} from "~/search/search-settings"; -export function getSearchProvider() { - return googleSearchProvider; +export const searchProviders: Record = { + duckduckgo: duckduckgoSearchProvider, + google: googleSearchProvider, + yandex: yandexSearchProvider +}; + +export type { CustomSearchSuggestionsProviderId, SearchEngineSettingId, SearchProviderId }; +export { isCustomSearchSuggestionsProviderId, isSearchEngineSettingId, isSearchProviderId }; + +let currentSearchSettings: SearchSettingsSnapshot = getDefaultSearchSettingsSnapshot(); +let hasInitializedSearchProviderSetting = false; + +function readSearchSettingsSnapshot(): SearchSettingsSnapshot { + if (typeof flow === "undefined") { + return getDefaultSearchSettingsSnapshot(); + } + + return flow.settings.getSearchSettingsSnapshotSync(); +} + +function createCustomSearchProvider(searchSettings: SearchSettingsSnapshot): SearchProvider { + const suggestionsProvider = + searchSettings.customSearchSuggestionsProvider !== "none" + ? searchProviders[searchSettings.customSearchSuggestionsProvider] + : null; + + return { + id: "custom", + label: "Custom Search Engine", + buildSearchUrl(query: string): string { + return ( + buildCustomSearchUrl(searchSettings.customSearchUrlTemplate, query) ?? + buildSearchUrlFromProviderId("google", query) + ); + }, + getSuggestions: suggestionsProvider?.getSuggestions?.bind(suggestionsProvider) + }; +} + +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(); +} + +function initializeSearchProviderSetting() { + if (hasInitializedSearchProviderSetting || typeof flow === "undefined") { + return; + } + + hasInitializedSearchProviderSetting = true; + flow.settings.onSettingsChanged((event) => { + if (event.searchSettingsSnapshot) { + currentSearchSettings = event.searchSettingsSnapshot; + } + }); + 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?: SearchEngineSettingId): SearchProvider { + initializeSearchProviderSetting(); + + if (id) { + return id === "custom" + ? createCustomSearchProvider(currentSearchSettings) + : createConfiguredBuiltInSearchProvider(id, currentSearchSettings); + } + + return currentSearchSettings.searchEngine === "custom" + ? createCustomSearchProvider(currentSearchSettings) + : createConfiguredBuiltInSearchProvider(currentSearchSettings.searchEngine, currentSearchSettings); } 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 000000000..2999ef070 --- /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 6dae78d68..51f50c490 100644 --- a/src/renderer/src/lib/omnibox-new/search-providers/types.ts +++ b/src/renderer/src/lib/omnibox-new/search-providers/types.ts @@ -1,33 +1,38 @@ export interface SearchProviderRequest { input: string; limit: number; - signal: AbortSignal; + 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; 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 000000000..08f6366d9 --- /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 new file mode 100644 index 000000000..8ca6b148f --- /dev/null +++ b/src/renderer/src/lib/omnibox-new/search-providers/yandex.ts @@ -0,0 +1,166 @@ +import type { + NavigationSearchProviderCompletion, + QuerySearchProviderCompletion, + SearchProvider, + SearchProviderRequest +} from "./types"; +import { mapSuggestionRelevanceByIndex } from "./suggestion-utils"; +import { normalizeAndValidateUrl } from "./url-utils"; +import { buildSearchUrlFromProviderId } from "~/search/search-settings"; + +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_SUGGEST_BASE_URL = "https://suggest.yandex.com/suggest-ff.cgi"; + +function buildSearchUrl(query: string): string { + return buildSearchUrlFromProviderId("yandex", query); +} + +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: mapSuggestionRelevanceByIndex(index) + }; + + return completion; + } + + const completion: QuerySearchProviderCompletion = { + kind: "query", + title: suggestion.phrase, + query: suggestion.phrase, + description: suggestion.description, + relevance: mapSuggestionRelevanceByIndex(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; + } +}; diff --git a/src/renderer/src/lib/omnibox-v1.ts b/src/renderer/src/lib/omnibox-v1.ts index 5dbc4539a..32f92c941 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 fc34b55fd..bb5361d46 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 c76062f8c..ce00a94ff 100644 --- a/src/renderer/src/lib/search.ts +++ b/src/renderer/src/lib/search.ts @@ -1,30 +1,22 @@ -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 + }); + + 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 5b8681ffa..b204ebff7 100644 --- a/src/shared/flow/interfaces/settings/settings.ts +++ b/src/shared/flow/interfaces/settings/settings.ts @@ -1,5 +1,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 { @@ -21,7 +27,18 @@ 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]>; + onSettingsChanged: IPCListener<[SettingsChangedEvent]>; } diff --git a/src/shared/search/custom-search.ts b/src/shared/search/custom-search.ts new file mode 100644 index 000000000..1aa1ddc84 --- /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 000000000..cc961eb92 --- /dev/null +++ b/src/shared/search/search-settings.ts @@ -0,0 +1,192 @@ +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, 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"); + } + 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; + duckduckgoAiEnabled: boolean; +} + +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", + duckduckgoAiEnabled: true +}; + +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", + "duckduckgoAiEnabled" +]; + +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, + options?: SearchUrlBuildOptions +): string { + return SEARCH_PROVIDER_METADATA[providerId].buildSearchUrl(query, options); +} + +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, + duckduckgoAiEnabled: + typeof searchSettings.duckduckgoAiEnabled === "boolean" + ? searchSettings.duckduckgoAiEnabled + : DEFAULT_SEARCH_SETTINGS_SNAPSHOT.duckduckgoAiEnabled + }; +} + +/** + * 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, { + duckduckgoAiEnabled: searchSettings.duckduckgoAiEnabled + }); +} diff --git a/src/shared/types/settings.ts b/src/shared/types/settings.ts index 5c06cbe25..3f0498ed7 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 //