Skip to content
Merged
13 changes: 11 additions & 2 deletions apps/web/src/components/preview/PreviewView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ import {
PREVIEW_ZOOM_LEVELS,
type PreviewAdjustInput,
} from "@t3tools/contracts";
import { normalizePreviewUrl, resolveAddressBarInput } from "@t3tools/shared/preview";
import {
normalizePreviewUrl,
resolveAddressBarInput,
resolveSearchUrlTemplate,
} from "@t3tools/shared/preview";
import { useCallback, useEffect, useRef, useState } from "react";

import {
Expand All @@ -30,6 +34,7 @@ import {
import { type ComposerImageAttachment, useComposerDraftStore } from "~/composerDraftStore";
import { capturePreviewAnnotationScreenshot } from "~/lib/previewAnnotation";
import { ensureLocalApi } from "~/localApi";
import { getClientSettings } from "~/hooks/useSettings";
import {
rememberPreviewUrl,
updatePreviewServerSnapshot,
Expand Down Expand Up @@ -296,7 +301,11 @@ export function PreviewView({
const handleSubmitUrl = useCallback(
async (next: string) => {
try {
const resolved = resolveAddressBarInput(next);
// Settings hydrate before a tab can be opened, so the snapshot is current.
const resolved = resolveAddressBarInput(
next,
resolveSearchUrlTemplate(getClientSettings()),
);
if (await navigateToResolvedUrl(resolved)) {
recordVisitForThread(threadRef, resolved);
}
Expand Down
84 changes: 84 additions & 0 deletions apps/web/src/components/settings/IntegrationsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
BROWSER_PROFILE_MAX_COUNT,
type BrowserLinkTarget,
type BrowserProfile,
BrowserSearchEngine,
DEFAULT_BROWSER_SEARCH_ENGINE,
type EnvironmentId,
BROWSER_PROFILE_NAME_MAX_LENGTH,
BROWSER_RECORDING_FRAME_RATES,
Expand All @@ -39,6 +41,7 @@ import {
type PreviewAppearancePreference,
type PreviewViewportSetting,
} from "@t3tools/contracts";
import { isValidSearchUrlTemplate } from "@t3tools/shared/preview";
import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport";
import { MoreVertical, Plus as PlusIcon } from "lucide-react";
import { useCallback, useRef, useState } from "react";
Expand Down Expand Up @@ -622,6 +625,85 @@ function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean })
);
}

const SEARCH_ENGINE_LABELS = {
duckduckgo: "DuckDuckGo",
google: "Google",
bing: "Bing",
brave: "Brave Search",
kagi: "Kagi",
ecosia: "Ecosia",
custom: "Custom",
} satisfies Record<BrowserSearchEngine, string>;

function BrowserSearchEngineSetting() {
const engine = useClientSettings((settings) => settings.browserSearchEngine);
const customUrl = useClientSettings((settings) => settings.browserCustomSearchUrl);
const updateSettings = useUpdatePrimarySettings();

return (
<>
<SettingsRow
{...searchableSetting("browser-search-engine")}
description="What the address bar searches with when you type something that isn't a URL."
resetAction={
engine !== DEFAULT_BROWSER_SEARCH_ENGINE ? (
<SettingResetButton
label="search engine"
onClick={() => updateSettings({ browserSearchEngine: DEFAULT_BROWSER_SEARCH_ENGINE })}
/>
) : null
}
control={
<Select
value={engine}
onValueChange={(value) => {
const next = BrowserSearchEngine.literals.find((literal) => literal === value);
if (next !== undefined) {
updateSettings({ browserSearchEngine: next });
}
}}
>
<SelectTrigger size="sm" className="w-full sm:w-40" aria-label="Search engine">
<SelectValue>{SEARCH_ENGINE_LABELS[engine]}</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
{BrowserSearchEngine.literals.map((literal) => (
<SelectItem hideIndicator key={literal} value={literal}>
{SEARCH_ENGINE_LABELS[literal]}
</SelectItem>
))}
</SelectPopup>
</Select>
}
/>
{engine === "custom" ? (
<SettingsRow
title="Custom search URL"
description="Use %s where the search terms go."
status={
customUrl !== "" && !isValidSearchUrlTemplate(customUrl)
? "Enter an http or https URL containing %s. Searches use DuckDuckGo until then."
: undefined
}
control={
<DraftInput
nativeInput
size="sm"
className="w-full sm:w-64"
aria-label="Custom search URL"
placeholder="https://example.com/search?q=%s"
autoCapitalize="none"
spellCheck={false}
value={customUrl}
onCommit={(next) => updateSettings({ browserCustomSearchUrl: next.trim() })}
/>
}
/>
) : null}
</>
);
}

function DeviceIntegrationSettings() {
const { search, environment: selected } = useSettingsScope();
const settings = useScopedSettings();
Expand Down Expand Up @@ -1545,6 +1627,8 @@ export function IntegrationsSettingsPanel() {
) : (
previewDefaults
)}
{/* Outside the desktop-only group: web clients search from server tabs too. */}
<BrowserSearchEngineSetting />
</SettingsSection>
<DeviceIntegrationSettings />
</SettingsPageContainer>
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export type BrowserDefaultSettings = Pick<
| "browserRecordingShowKeyPresses"
| "browserRecordingShowMousePresses"
| "browserLinkTarget"
| "browserSearchEngine"
| "browserAutoShowFloatingPreview"
>;

Expand Down Expand Up @@ -162,6 +163,9 @@ export function getChangedBrowserSettingLabels(settings: BrowserDefaultSettings)
...(settings.browserLinkTarget !== DEFAULT_UNIFIED_SETTINGS.browserLinkTarget
? ["Open links in"]
: []),
...(settings.browserSearchEngine !== DEFAULT_UNIFIED_SETTINGS.browserSearchEngine
? ["Search engine"]
: []),
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
...(settings.browserAutoShowFloatingPreview !==
DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview
? ["Floating preview"]
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.browserRecordingShowKeyPresses,
settings.browserRecordingShowMousePresses,
settings.browserLinkTarget,
settings.browserSearchEngine,
settings.browserAutoShowFloatingPreview,
settings.appearanceContrast,
settings.diffColorScheme,
Expand Down Expand Up @@ -847,6 +848,8 @@ export function useSettingsRestore(onRestored?: () => void) {
browserRecordingShowKeyPresses: DEFAULT_UNIFIED_SETTINGS.browserRecordingShowKeyPresses,
browserRecordingShowMousePresses: DEFAULT_UNIFIED_SETTINGS.browserRecordingShowMousePresses,
browserLinkTarget: DEFAULT_UNIFIED_SETTINGS.browserLinkTarget,
browserSearchEngine: DEFAULT_UNIFIED_SETTINGS.browserSearchEngine,
browserCustomSearchUrl: DEFAULT_UNIFIED_SETTINGS.browserCustomSearchUrl,
browserAutoShowFloatingPreview: DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview,
// Re-granted like any other default. The confirmation dialog lists it by
// name, so a user restoring defaults is told the agent regains access
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,12 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/integrations",
searchTerms: ["links default browser in-app browser external open"],
},
{
id: "browser-search-engine",
title: "Search engine",
to: "/settings/integrations",
searchTerms: ["address bar url duckduckgo google bing brave kagi ecosia custom query"],
},
{
id: "browser-auto-show-floating-preview",
title: "Auto-show floating preview",
Expand Down
25 changes: 25 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,22 @@ export const DEFAULT_BROWSER_RECORDING_FRAME_RATE: BrowserRecordingFrameRate = 3
export const BrowserLinkTarget = Schema.Literals(["system", "app"]);
export type BrowserLinkTarget = typeof BrowserLinkTarget.Type;
export const DEFAULT_BROWSER_LINK_TARGET: BrowserLinkTarget = "system";
/**
* What the address bar searches with when the input is not an address.
* "custom" uses `browserCustomSearchUrl`, a template with `%s` for the query.
*/
export const BrowserSearchEngine = Schema.Literals([
"duckduckgo",
"google",
"bing",
"brave",
"kagi",
"ecosia",
"custom",
]);
export type BrowserSearchEngine = typeof BrowserSearchEngine.Type;
export const DEFAULT_BROWSER_SEARCH_ENGINE: BrowserSearchEngine = "duckduckgo";
export const BrowserCustomSearchUrl = Schema.String.check(Schema.isMaxLength(2048));

export const LoadBalancingWeights = Schema.Record(
TrimmedNonEmptyString,
Expand Down Expand Up @@ -342,6 +358,13 @@ export const ClientSettingsSchema = Schema.Struct({
browserLinkTarget: BrowserLinkTarget.pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_LINK_TARGET)),
),
browserSearchEngine: BrowserSearchEngine.pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_SEARCH_ENGINE)),
),
// Kept when switching away from "custom" so switching back restores it.
browserCustomSearchUrl: BrowserCustomSearchUrl.pipe(
Schema.withDecodingDefault(Effect.succeed("")),
),
/**
* Whether an agent using a preview pops the floating mini player into
* view. Only applies when the agent didn't ask either way — an explicit
Expand Down Expand Up @@ -1599,6 +1622,8 @@ export const ClientSettingsPatch = Schema.Struct({
browserRecordingShowKeyPresses: Schema.optionalKey(Schema.Boolean),
browserRecordingShowMousePresses: Schema.optionalKey(Schema.Boolean),
browserLinkTarget: Schema.optionalKey(BrowserLinkTarget),
browserSearchEngine: Schema.optionalKey(BrowserSearchEngine),
browserCustomSearchUrl: Schema.optionalKey(BrowserCustomSearchUrl),
browserAutoShowFloatingPreview: Schema.optionalKey(Schema.Boolean),
browserProfiles: Schema.optionalKey(Schema.Array(BrowserProfile)),
browserDefaultProfileId: Schema.optionalKey(BrowserProfileId),
Expand Down
35 changes: 35 additions & 0 deletions packages/shared/src/preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
normalizePreviewUrl,
PreviewUrlNormalizationError,
resolveAddressBarInput,
resolveSearchUrlTemplate,
} from "./preview.ts";

describe("resolveAddressBarInput", () => {
Expand Down Expand Up @@ -55,6 +56,40 @@ describe("resolveAddressBarInput", () => {
}
expect(resolveAddressBarInput("devbox:8080")).toBe("https://devbox:8080/");
});

it("searches with the given template and still opens addresses directly", () => {
const google = resolveSearchUrlTemplate({
browserSearchEngine: "google",
browserCustomSearchUrl: "",
});
expect(resolveAddressBarInput("how to center a div", google)).toBe(
"https://www.google.com/search?q=how%20to%20center%20a%20div",
);
expect(resolveAddressBarInput("cnn.com", google)).toBe("https://cnn.com/");
expect(resolveAddressBarInput("a&b", "https://search.example/?q=%s&lang=en")).toBe(
"https://search.example/?q=a%26b&lang=en",
);
});
});

describe("resolveSearchUrlTemplate", () => {
it("uses a valid custom template", () => {
expect(
resolveSearchUrlTemplate({
browserSearchEngine: "custom",
browserCustomSearchUrl: " https://search.example/?q=%s ",
}),
).toBe("https://search.example/?q=%s");
});

it.each(["", "https://search.example/?q=", "javascript:alert('%s')", "not a url %s"])(
"falls back to DuckDuckGo for an unusable custom template: %j",
(browserCustomSearchUrl) => {
expect(
resolveSearchUrlTemplate({ browserSearchEngine: "custom", browserCustomSearchUrl }),
).toBe("https://duckduckgo.com/?q=%s");
},
);
});

describe("newPreviewTabId", () => {
Expand Down
58 changes: 51 additions & 7 deletions packages/shared/src/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* on what counts as "loopback" and how to normalise a free-form URL string.
*/

import type { BrowserSearchEngine } from "@t3tools/contracts";
import * as Schema from "effect/Schema";

const TAB_ID_PREFIX = "tab_";
Expand Down Expand Up @@ -57,7 +58,6 @@ function previewUrlProtocol(rawUrl: string): string | undefined {
return /^([A-Za-z][A-Za-z\d+.-]*):/.exec(rawUrl)?.[1]?.toLowerCase().concat(":");
}

const SEARCH_URL = "https://duckduckgo.com/?q=";
/** A bare host that is always an address: localhost, an IPv4 literal, or a bracketed IPv6 one. */
const ADDRESS_HOST_PATTERN = /^(?:localhost|\d{1,3}(?:\.\d{1,3}){3}|\[[\da-f:.]+\])$/i;
const BARE_HOST_PORT_PATTERN = /^[^/?#:@]+:\d+(?:[/?#]|$)/;
Expand All @@ -82,14 +82,58 @@ const KNOWN_NON_WEB_SCHEMES: ReadonlySet<string> = new Set([
"wss:",
]);

/** Address bar search URL templates; `%s` is replaced with the encoded query. */
export const BROWSER_SEARCH_URL_TEMPLATES = {
duckduckgo: "https://duckduckgo.com/?q=%s",
google: "https://www.google.com/search?q=%s",
bing: "https://www.bing.com/search?q=%s",
brave: "https://search.brave.com/search?q=%s",
kagi: "https://kagi.com/search?q=%s",
ecosia: "https://www.ecosia.org/search?q=%s",
} satisfies Record<Exclude<BrowserSearchEngine, "custom">, string>;

function fillSearchUrlTemplate(template: string, query: string) {
return template.replaceAll("%s", encodeURIComponent(query));
}

/** Whether a custom search URL is an http(s) URL with `%s` where the query goes. */
export function isValidSearchUrlTemplate(template: string) {
if (!template.includes("%s")) return false;
try {
const { protocol } = new URL(fillSearchUrlTemplate(template, "test"));
return protocol === "http:" || protocol === "https:";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
} catch {
return false;
}
}

/**
* The search URL template for the user's chosen engine. An unusable custom
* template falls back to DuckDuckGo, so a query typed in the address bar
* always searches somewhere.
*/
export function resolveSearchUrlTemplate(settings: {
readonly browserSearchEngine: BrowserSearchEngine;
readonly browserCustomSearchUrl: string;
}) {
if (settings.browserSearchEngine !== "custom") {
return BROWSER_SEARCH_URL_TEMPLATES[settings.browserSearchEngine];
}
const custom = settings.browserCustomSearchUrl.trim();
return isValidSearchUrlTemplate(custom) ? custom : BROWSER_SEARCH_URL_TEMPLATES.duckduckgo;
}

/**
* Turns what a user typed in an address bar into the URL to open: an address
* as `normalizePreviewUrl` reads it, or else a web search for the text. Text
* is an address when it has a scheme, or no spaces and a host that has a dot,
* a port, or is localhost or an IP. Throws only for empty input or an
* explicit unsupported scheme (`ftp://`, `mailto:`, `data:`).
* as `normalizePreviewUrl` reads it, or else a web search for the text using
* `searchUrlTemplate`. Text is an address when it has a scheme, or no spaces
* and a host that has a dot, a port, or is localhost or an IP. Throws only for
* empty input or an explicit unsupported scheme (`ftp://`, `mailto:`, `data:`).
*/
export function resolveAddressBarInput(rawInput: string): string {
export function resolveAddressBarInput(
rawInput: string,
searchUrlTemplate: string = BROWSER_SEARCH_URL_TEMPLATES.duckduckgo,
): string {
const trimmed = rawInput.trim();
if (/^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) || trimmed.length === 0)
return normalizePreviewUrl(trimmed);
Expand Down Expand Up @@ -122,7 +166,7 @@ export function resolveAddressBarInput(rawInput: string): string {
}
}
}
return `${SEARCH_URL}${encodeURIComponent(trimmed)}`;
return fillSearchUrlTemplate(searchUrlTemplate, trimmed);
}

/**
Expand Down
Loading