Skip to content
Merged
9 changes: 7 additions & 2 deletions apps/web/src/components/preview/PreviewView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,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 { useEffect, useEffectEvent, useRef, useState } from "react";

import {
Expand All @@ -31,6 +35,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 @@ -279,7 +284,7 @@ export function PreviewView({

const handleSubmitUrl = async (next: string) => {
try {
const resolved = resolveAddressBarInput(next);
const resolved = resolveAddressBarInput(next, resolveSearchUrlTemplate(getClientSettings()));
if (await navigateToResolvedUrl(resolved)) {
recordVisitForThread(threadRef, resolved);
}
Expand Down
91 changes: 91 additions & 0 deletions apps/web/src/components/settings/IntegrationsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@ import {
BROWSER_PROFILE_MAX_COUNT,
type BrowserLinkTarget,
type BrowserProfile,
BrowserSearchEngine,
BROWSER_CUSTOM_SEARCH_URL_MAX_LENGTH,
type EnvironmentId,
BROWSER_PROFILE_NAME_MAX_LENGTH,
BROWSER_RECORDING_FRAME_RATES,
DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW,
DEFAULT_BROWSER_PROFILE_ID,
DEFAULT_BROWSER_LINK_TARGET,
DEFAULT_BROWSER_RECORDING_FRAME_RATE,
DEFAULT_BROWSER_SEARCH_ENGINE,
DEFAULT_BROWSER_VIEWPORT,
DEFAULT_PREVIEW_APPEARANCE,
DEFAULT_PREVIEW_ZOOM_FACTOR,
Expand All @@ -31,6 +34,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 { useRef, useState } from "react";
Expand Down Expand Up @@ -682,6 +686,91 @@ function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean })
);
}

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

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 when you type something that isn't a URL."
resetAction={
engine !== DEFAULT_BROWSER_SEARCH_ENGINE || customUrl !== "" ? (
<SettingResetButton
label="search engine"
onClick={() =>
updateSettings({
browserSearchEngine: DEFAULT_BROWSER_SEARCH_ENGINE,
browserCustomSearchUrl: "",
})
}
/>
) : 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={
isValidSearchUrlTemplate(customUrl)
? undefined
: "Enter an http or https URL containing %s. Searches use DuckDuckGo until then."
}
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}
maxLength={BROWSER_CUSTOM_SEARCH_URL_MAX_LENGTH}
value={customUrl}
onCommit={(next) => updateSettings({ browserCustomSearchUrl: next.trim() })}
/>
}
/>
) : null}
</>
);
}

function PullRequestLinksOpenInAppSetting() {
const openInApp = useClientSettings((settings) => settings.pullRequestLinksOpenInApp);
const updateSettings = useUpdatePrimarySettings();
Expand Down Expand Up @@ -1494,6 +1583,8 @@ export function IntegrationsSettingsPanel() {
) : (
previewDefaults
)}
{/* Not desktop-only: web clients search from server-run browser tabs too. */}
<BrowserSearchEngineSetting />
<PullRequestLinksOpenInAppSetting />
</SettingsSection>
<DeviceIntegrationSettings />
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,15 @@ describe("getChangedBrowserSettingLabels", () => {
).toEqual([]);
});

it("counts a retained custom search URL so restore clears it", () => {
expect(
getChangedBrowserSettingLabels({
...DEFAULT_UNIFIED_SETTINGS,
browserCustomSearchUrl: "https://search.example/?q=%s",
}),
).toEqual(["Search engine"]);
});

it("labels each browser default that differs", () => {
expect(
getChangedBrowserSettingLabels({
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ export type BrowserDefaultSettings = Pick<
| "browserRecordingShowKeyPresses"
| "browserRecordingShowMousePresses"
| "browserLinkTarget"
| "browserSearchEngine"
| "browserCustomSearchUrl"
| "browserAutoShowFloatingPreview"
| "pullRequestLinksOpenInApp"
>;
Expand Down Expand Up @@ -152,6 +154,10 @@ export function getChangedBrowserSettingLabels(settings: BrowserDefaultSettings)
...(settings.browserLinkTarget !== DEFAULT_UNIFIED_SETTINGS.browserLinkTarget
? ["Open links in"]
: []),
...(settings.browserSearchEngine !== DEFAULT_UNIFIED_SETTINGS.browserSearchEngine ||
settings.browserCustomSearchUrl !== DEFAULT_UNIFIED_SETTINGS.browserCustomSearchUrl
? ["Search engine"]
: []),
...(settings.browserAutoShowFloatingPreview !==
DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview
? ["Floating preview"]
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,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,
pullRequestLinksOpenInApp: DEFAULT_UNIFIED_SETTINGS.pullRequestLinksOpenInApp,
enableAgentBrowserAccess: DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess,
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 @@ -745,6 +745,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 duckduckgo google bing brave kagi ecosia custom"],
},
{
id: "pull-request-links-open-in-app",
title: "Open pull requests in T3 Code",
Expand Down
30 changes: 30 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,25 @@ 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 BROWSER_CUSTOM_SEARCH_URL_MAX_LENGTH = 2048;
export const BrowserCustomSearchUrl = Schema.String.check(
Schema.isMaxLength(BROWSER_CUSTOM_SEARCH_URL_MAX_LENGTH),
);

export const LoadBalancingWeights = Schema.Record(
TrimmedNonEmptyString,
Expand Down Expand Up @@ -333,6 +352,15 @@ export const ClientSettingsSchema = Schema.Struct({
Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_LINK_TARGET)),
),

browserSearchEngine: BrowserSearchEngine.pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_SEARCH_ENGINE)),
),

// Kept when the engine is switched away from "custom" so switching back restores it.
browserCustomSearchUrl: BrowserCustomSearchUrl.pipe(
Schema.withDecodingDefault(Effect.succeed("")),
),

browserAutoShowFloatingPreview: Schema.Boolean.pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW)),
),
Expand Down Expand Up @@ -1535,6 +1563,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
36 changes: 36 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 @@ -54,6 +55,41 @@ 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 and falls back to DuckDuckGo for an unusable one", () => {
expect(
resolveSearchUrlTemplate({
browserSearchEngine: "custom",
browserCustomSearchUrl: " https://search.example/?q=%s ",
}),
).toBe("https://search.example/?q=%s");
for (const browserCustomSearchUrl of [
"https://search.example/?q=",
"javascript:alert('%s')",
"https://%s.example.com/",
"https://%s@search.example/?q=%s",
]) {
expect(
resolveSearchUrlTemplate({ browserSearchEngine: "custom", browserCustomSearchUrl }),
).toBe("https://duckduckgo.com/?q=%s");
}
});
});

describe("newPreviewTabId", () => {
Expand Down
58 changes: 55 additions & 3 deletions packages/shared/src/preview.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { BrowserSearchEngine } from "@t3tools/contracts";
import * as Schema from "effect/Schema";

const TAB_ID_PREFIX = "tab_";
Expand Down Expand Up @@ -46,7 +47,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=";
const ADDRESS_HOST_PATTERN = /^(?:localhost|\d{1,3}(?:\.\d{1,3}){3}|\[[\da-f:.]+\])$/i;
const BARE_HOST_PORT_PATTERN = /^[^/?#:@]+:\d+(?:[/?#]|$)/;
const KNOWN_NON_WEB_SCHEMES: ReadonlySet<string> = new Set([
Expand All @@ -66,7 +66,59 @@ const KNOWN_NON_WEB_SCHEMES: ReadonlySet<string> = new Set([
"wss:",
]);

export function resolveAddressBarInput(rawInput: string): string {
/** Address bar search URL templates; `%s` is replaced with the encoded query. */
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): string {
return template.replaceAll("%s", encodeURIComponent(query));
}

/**
* Whether a custom search URL is an http(s) URL with `%s` where the query goes.
* `%s` in the host or credentials is rejected: the query would never reach a
* search page, and a query with spaces would make the host unreachable.
*/
export function isValidSearchUrlTemplate(template: string): boolean {
if (!template.includes("%s")) return false;
try {
// Two queries land on different authorities only when `%s` sits before the path.
const authority = (url: URL) => `${url.username}:${url.password}@${url.host}`;
const url = new URL(fillSearchUrlTemplate(template, "a"));
const other = new URL(fillSearchUrlTemplate(template, "b"));
return (
(url.protocol === "http:" || url.protocol === "https:") && authority(url) === authority(other)
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch {
return false;
}
}

/**
* The search URL template for the chosen engine. An unusable custom template
* falls back to DuckDuckGo so a query typed in the address bar always searches.
*/
export function resolveSearchUrlTemplate(settings: {
readonly browserSearchEngine: BrowserSearchEngine;
readonly browserCustomSearchUrl: string;
}): 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;
}

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 @@ -97,7 +149,7 @@ export function resolveAddressBarInput(rawInput: string): string {
} catch {}
}
}
return `${SEARCH_URL}${encodeURIComponent(trimmed)}`;
return fillSearchUrlTemplate(searchUrlTemplate, trimmed);
}

export function normalizePreviewUrl(rawUrl: string): string {
Expand Down
Loading