diff --git a/apps/web/src/browser/integratedBrowserLinkPatterns.test.ts b/apps/web/src/browser/integratedBrowserLinkPatterns.test.ts new file mode 100644 index 000000000000..774abe8ce7cd --- /dev/null +++ b/apps/web/src/browser/integratedBrowserLinkPatterns.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + normalizeIntegratedBrowserUrlPattern, + parseIntegratedBrowserUrlPattern, + urlMatchesIntegratedBrowserPatterns, +} from "./integratedBrowserLinkPatterns"; + +describe("parseIntegratedBrowserUrlPattern", () => { + it("parses host-only patterns, lowercasing and stripping www", () => { + expect(parseIntegratedBrowserUrlPattern(" GitHub.com ")).toEqual({ + host: "github.com", + pathPrefix: null, + }); + expect(parseIntegratedBrowserUrlPattern("www.github.com")).toEqual({ + host: "github.com", + pathPrefix: null, + }); + }); + + it("parses path prefixes, dropping trailing slashes", () => { + expect(parseIntegratedBrowserUrlPattern("docs.example.com/api/")).toEqual({ + host: "docs.example.com", + pathPrefix: "/api", + }); + expect(parseIntegratedBrowserUrlPattern("example.com/")).toEqual({ + host: "example.com", + pathPrefix: null, + }); + expect(parseIntegratedBrowserUrlPattern("example.com/docs/v1:api")).toEqual({ + host: "example.com", + pathPrefix: "/docs/v1:api", + }); + }); + + it("canonicalizes path prefixes to the URL.pathname representation", () => { + expect(parseIntegratedBrowserUrlPattern("example.com/über")).toEqual({ + host: "example.com", + pathPrefix: "/%C3%BCber", + }); + }); + + it("rejects schemes, ports, whitespace, malformed hosts, and non-leading wildcards", () => { + for (const raw of [ + "", + " ", + "https://github.com", + "github.com:8080", + "git hub.com", + "/just/a/path", + "héllo.com", + "example.com/docs?view=full", + "example.com/docs#anchor", + "gist*.github.com", + "*.gist*.github.com", + "www.*.example.com", + "example..com", + ".example.com", + "example.com.", + "-example.com", + "example-.com", + "*", + "*.", + ]) { + expect(parseIntegratedBrowserUrlPattern(raw)).toBeNull(); + } + }); +}); + +describe("normalizeIntegratedBrowserUrlPattern", () => { + it("wildcards bare domains and keeps more specific patterns as entered", () => { + expect(normalizeIntegratedBrowserUrlPattern("github.com")).toBe("*.github.com"); + expect(normalizeIntegratedBrowserUrlPattern("GitHub.com/T3")).toBe("*.github.com/T3"); + expect(normalizeIntegratedBrowserUrlPattern("docs.example.com/api/")).toBe( + "docs.example.com/api", + ); + expect(normalizeIntegratedBrowserUrlPattern("*.vercel.app")).toBe("*.vercel.app"); + expect(normalizeIntegratedBrowserUrlPattern("localhost")).toBe("localhost"); + expect(normalizeIntegratedBrowserUrlPattern("https://github.com")).toBeNull(); + }); +}); + +describe("urlMatchesIntegratedBrowserPatterns", () => { + it("matches exact hosts case-insensitively, ignoring ports and www", () => { + expect(urlMatchesIntegratedBrowserPatterns("https://GitHub.com/t3", ["github.com"])).toBe(true); + expect(urlMatchesIntegratedBrowserPatterns("https://www.github.com", ["github.com"])).toBe( + true, + ); + expect(urlMatchesIntegratedBrowserPatterns("https://github.com", ["www.github.com"])).toBe( + true, + ); + expect(urlMatchesIntegratedBrowserPatterns("http://localhost:5173/x", ["localhost"])).toBe( + true, + ); + }); + + it("matches the apex and any subdomain depth for leading wildcards", () => { + const patterns = ["*.github.com"]; + expect(urlMatchesIntegratedBrowserPatterns("https://github.com", patterns)).toBe(true); + expect(urlMatchesIntegratedBrowserPatterns("https://gist.github.com", patterns)).toBe(true); + expect(urlMatchesIntegratedBrowserPatterns("https://a.b.github.com", patterns)).toBe(true); + expect(urlMatchesIntegratedBrowserPatterns("https://notgithub.com", patterns)).toBe(false); + }); + + it("matches wildcard apexes that name www against the unstripped hostname", () => { + const patterns = ["*.www.example.com"]; + expect(urlMatchesIntegratedBrowserPatterns("https://www.example.com", patterns)).toBe(true); + expect(urlMatchesIntegratedBrowserPatterns("https://a.www.example.com", patterns)).toBe(true); + expect(urlMatchesIntegratedBrowserPatterns("https://example.com", patterns)).toBe(false); + }); + + it("matches plain hosts exactly, without subdomains", () => { + expect( + urlMatchesIntegratedBrowserPatterns("https://docs.example.com", ["docs.example.com"]), + ).toBe(true); + expect( + urlMatchesIntegratedBrowserPatterns("https://v2.docs.example.com", ["docs.example.com"]), + ).toBe(false); + }); + + it("matches path prefixes on segment boundaries only", () => { + const patterns = ["docs.example.com/api"]; + expect(urlMatchesIntegratedBrowserPatterns("https://docs.example.com/api", patterns)).toBe( + true, + ); + expect(urlMatchesIntegratedBrowserPatterns("https://docs.example.com/api/v2", patterns)).toBe( + true, + ); + expect(urlMatchesIntegratedBrowserPatterns("https://docs.example.com/api-keys", patterns)).toBe( + false, + ); + }); + + it("matches non-ASCII path prefixes against percent-encoded pathnames", () => { + const patterns = ["example.com/über"]; + expect(urlMatchesIntegratedBrowserPatterns("https://example.com/über/docs", patterns)).toBe( + true, + ); + expect(urlMatchesIntegratedBrowserPatterns("https://example.com/%C3%BCber", patterns)).toBe( + true, + ); + expect(urlMatchesIntegratedBrowserPatterns("https://example.com/%c3%bcber", patterns)).toBe( + true, + ); + expect( + urlMatchesIntegratedBrowserPatterns("https://example.com/über", ["example.com/%c3%bcber"]), + ).toBe(true); + }); + + it("never matches non-http schemes, unparseable hrefs, or invalid patterns", () => { + expect(urlMatchesIntegratedBrowserPatterns("mailto:hi@github.com", ["github.com"])).toBe(false); + expect(urlMatchesIntegratedBrowserPatterns("not a url", ["github.com"])).toBe(false); + expect( + urlMatchesIntegratedBrowserPatterns("https://github.com", ["https://github.com", " "]), + ).toBe(false); + }); +}); diff --git a/apps/web/src/browser/integratedBrowserLinkPatterns.ts b/apps/web/src/browser/integratedBrowserLinkPatterns.ts new file mode 100644 index 000000000000..88481f43a480 --- /dev/null +++ b/apps/web/src/browser/integratedBrowserLinkPatterns.ts @@ -0,0 +1,174 @@ +// Matching for the "always open in integrated browser" URL patterns stored in +// `ClientSettings.integratedBrowserUrlPatterns`. Patterns are host names with +// an optional path prefix — `*.github.com`, `docs.example.com/api`. A leading +// `*.` matches the apex domain and any subdomain; a plain host matches only +// itself. Bare domains normalize to `*.domain.com` on entry. No schemes, +// ports, queries, or fragments. + +export interface IntegratedBrowserUrlPattern { + /** Lowercased host pattern with any leading `www.` stripped. */ + readonly host: string; + /** Normalized path prefix (leading `/`, no trailing `/`), or null for host-only patterns. */ + readonly pathPrefix: string | null; +} + +const HOST_PATTERN_CHARS = /^[a-z0-9*.-]+$/u; + +function stripWww(host: string): string { + return host.startsWith("www.") ? host.slice(4) : host; +} + +/** + * Uppercases percent-escape hex digits so equivalent escapes compare equal + * (`URL.pathname` preserves the casing it was given, so `/%c3%bcber` and + * `/%C3%BCber` would otherwise never prefix-match). + */ +function normalizePercentEscapes(pathname: string): string { + return pathname.replace(/%[0-9a-f]{2}/giu, (escape) => escape.toUpperCase()); +} + +/** + * Parses a raw user-entered pattern. Returns null when the pattern is invalid + * (schemes, ports, whitespace, queries, fragments, empty host, or illegal + * host characters). Matching compares `URL.pathname`, so `?`/`#` in a + * pattern could never match and are rejected up front. Schemes and ports are + * caught by the host character check — both leave a `:` in the host segment. + */ +export function parseIntegratedBrowserUrlPattern(raw: string): IntegratedBrowserUrlPattern | null { + const trimmed = raw.trim(); + if (trimmed.length === 0 || /[\s?#]/u.test(trimmed)) { + return null; + } + + const slashIndex = trimmed.indexOf("/"); + const rawHost = slashIndex === -1 ? trimmed : trimmed.slice(0, slashIndex); + const rawPath = slashIndex === -1 ? null : trimmed.slice(slashIndex); + + const lowerHost = rawHost.toLowerCase(); + if (!HOST_PATTERN_CHARS.test(lowerHost)) { + return null; + } + // `*` is only meaningful as a leading `*.` wildcard with a non-empty apex; + // reject it anywhere else so a pattern that would silently never match is + // flagged at entry. Validated before `www.` stripping — stripping first + // would silently widen `www.*.example.com` into a valid `*.example.com`. + if (lowerHost.includes("*")) { + const apex = lowerHost.startsWith("*.") ? lowerHost.slice(2) : ""; + if (apex.length === 0 || apex.includes("*")) { + return null; + } + } + const host = stripWww(lowerHost); + if (host.length === 0) { + return null; + } + // Reject structurally invalid hosts (`example..com`, `.example.com`, + // `-example.com`, `example.com.`) — they pass the character check but can + // never match a real link hostname, so the pattern would be silently dead. + const labels = (host.startsWith("*.") ? host.slice(2) : host).split("."); + if (labels.some((label) => label.length === 0 || label.startsWith("-") || label.endsWith("-"))) { + return null; + } + + if (rawPath === null || rawPath === "/") { + return { host, pathPrefix: null }; + } + // Canonicalize through URL so the prefix uses the same representation as + // the `URL.pathname` it is compared against (e.g. `/über` → `/%C3%BCber`). + let pathname: string; + try { + pathname = normalizePercentEscapes(new URL(`https://h${rawPath}`).pathname); + } catch { + return null; + } + if (pathname === "/") { + return { host, pathPrefix: null }; + } + return { + host, + pathPrefix: pathname.endsWith("/") ? pathname.slice(0, -1) : pathname, + }; +} + +/** + * Canonical string form for storage and display. A bare domain with no + * subdomain specified becomes `*.domain.com`; anything more specific (a + * subdomain, a wildcard, a single-label host) is kept as entered. Returns + * null for invalid patterns. + */ +export function normalizeIntegratedBrowserUrlPattern(raw: string): string | null { + const pattern = parseIntegratedBrowserUrlPattern(raw); + if (pattern === null) { + return null; + } + const host = + !pattern.host.includes("*") && pattern.host.split(".").length === 2 + ? `*.${pattern.host}` + : pattern.host; + return `${host}${pattern.pathPrefix ?? ""}`; +} + +/** + * A leading `*.` matches the apex domain and any subdomain at any depth: + * `*.github.com` matches `github.com`, `gist.github.com`, `a.b.github.com`. + * A plain host matches only itself. + */ +function hostMatchesPattern(host: string, hostPattern: string): boolean { + if (hostPattern.startsWith("*.")) { + const apex = hostPattern.slice(2); + return host === apex || host.endsWith(`.${apex}`); + } + return host === hostPattern; +} + +function pathMatchesPrefix(pathname: string, prefix: string): boolean { + if (!pathname.startsWith(prefix)) { + return false; + } + const rest = pathname.slice(prefix.length); + return rest.length === 0 || rest.startsWith("/"); +} + +/** + * Returns true when `href` is an http(s) URL whose host (and path, if the + * pattern has one) matches any of the raw patterns. Invalid patterns and + * unparseable hrefs never match. + */ +export function urlMatchesIntegratedBrowserPatterns( + href: string, + patterns: readonly string[], +): boolean { + if (patterns.length === 0) { + return false; + } + + let url: URL; + try { + url = new URL(href); + } catch { + return false; + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + return false; + } + + // Match both the raw and www-stripped hostname: stripping lets `example.com` + // patterns match `www.example.com` links, while the raw form keeps patterns + // that name `www` explicitly (e.g. a `*.www.example.com` apex) working. + const rawHostname = url.hostname.toLowerCase(); + const strippedHost = stripWww(rawHostname); + return patterns.some((raw) => { + const pattern = parseIntegratedBrowserUrlPattern(raw); + if ( + pattern === null || + (!hostMatchesPattern(strippedHost, pattern.host) && + !hostMatchesPattern(rawHostname, pattern.host)) + ) { + return false; + } + return ( + pattern.pathPrefix === null || + pathMatchesPrefix(normalizePercentEscapes(url.pathname), pattern.pathPrefix) + ); + }); +} diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 1335e6bb05b2..866a4abb6070 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -84,6 +84,7 @@ import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; +import { urlMatchesIntegratedBrowserPatterns } from "../browser/integratedBrowserLinkPatterns"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; import { isBrowserPreviewFile, @@ -1459,6 +1460,17 @@ function ChatMarkdown({ const isSameDocumentLink = href?.startsWith("#") ?? false; const onClick = props.onClick; const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); + const openInPreviewReportingFailure = async (target: string): Promise => { + const result = await openExternalLinkInPreview(target); + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) { + return true; + } + reportMarkdownActionFailure( + { operation: "open-link-in-preview", target }, + result.cause, + ); + return false; + }; const link = ( { + // Default navigation was prevented, so a failed preview + // open must fall back to the system browser. + if (opened) return; + const api = readLocalApi(); + if (api) { + void api.shell.openExternal(href).catch((cause: unknown) => { + reportMarkdownActionFailure( + { operation: "open-link-external", target: href }, + cause, + ); + }); + } else { + window.open(href, "_blank", "noopener,noreferrer"); + } + }); }} onContextMenu={(event) => { if (!canOpenInPreview || !href || !faviconHost) return; @@ -1482,13 +1530,7 @@ function ChatMarkdown({ position: { x: event.clientX, y: event.clientY }, showContextMenu: (items, position) => api.contextMenu.show(items, position), openInPreview: async (target) => { - const result = await openExternalLinkInPreview(target); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - reportMarkdownActionFailure( - { operation: "open-link-in-preview", target }, - result.cause, - ); - } + await openInPreviewReportingFailure(target); }, openExternal: (target) => api.shell.openExternal(target), copyLink: (target) => writeTextToClipboard(target, "link"), diff --git a/apps/web/src/components/settings/IntegratedBrowserLinksSetting.tsx b/apps/web/src/components/settings/IntegratedBrowserLinksSetting.tsx new file mode 100644 index 000000000000..2c0e2e8d3269 --- /dev/null +++ b/apps/web/src/components/settings/IntegratedBrowserLinksSetting.tsx @@ -0,0 +1,103 @@ +import { PlusIcon, XIcon } from "lucide-react"; +import { useState } from "react"; + +import { normalizeIntegratedBrowserUrlPattern } from "../../browser/integratedBrowserLinkPatterns"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; + +interface IntegratedBrowserLinksSettingProps { + readonly patterns: readonly string[]; + readonly onChange: (next: readonly string[]) => void; +} + +/** + * Add/remove editor for the URL patterns that make chat links open in the + * integrated browser panel. New entries are canonicalized on add via + * `normalizeIntegratedBrowserUrlPattern` (bare domains → `*.domain.com`); + * persisted entries are shown as stored. + */ +export function IntegratedBrowserLinksSetting({ + patterns, + onChange, +}: IntegratedBrowserLinksSettingProps) { + const [input, setInput] = useState(""); + const [error, setError] = useState(null); + + const handleAdd = () => { + const trimmed = input.trim(); + if (trimmed.length === 0) { + setError("Enter a URL pattern."); + return; + } + const normalized = normalizeIntegratedBrowserUrlPattern(trimmed); + if (normalized === null) { + setError( + "Use a host with an optional path, like github.com or docs.example.com/api — no scheme or port.", + ); + return; + } + if (patterns.includes(normalized)) { + setError("That pattern is already in the list."); + return; + } + onChange([...patterns, normalized]); + setInput(""); + setError(null); + }; + + const handleRemove = (pattern: string) => { + onChange(patterns.filter((entry) => entry !== pattern)); + setError(null); + }; + + return ( +
+ {patterns.length > 0 ? ( +
+ {patterns.map((pattern) => ( +
+ {pattern} + +
+ ))} +
+ ) : null} + +
+ { + setInput(event.target.value); + if (error) setError(null); + }} + onKeyDown={(event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + handleAdd(); + }} + placeholder="github.com or *.vercel.app" + spellCheck={false} + aria-label="Integrated browser URL pattern" + /> + +
+ + {error ?

{error}

: null} +
+ ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 9a5fe3195685..882d08b87c80 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -140,6 +140,7 @@ import { type ProviderUpdateCandidate, } from "../ProviderUpdateLaunchNotification.logic"; import { ProviderInstanceCard } from "./ProviderInstanceCard"; +import { IntegratedBrowserLinksSetting } from "./IntegratedBrowserLinksSetting"; import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; import { backgroundActivitySharedPolicySettings, @@ -650,12 +651,14 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete ? ["Delete confirmation"] : []), + ...(settings.integratedBrowserUrlPatterns.length > 0 ? ["Integrated browser links"] : []), ...(isTextGenerationModelDirty ? ["Text generation model"] : []), ], [ isTextGenerationModelDirty, isBackgroundActivityDirty, settings.autoOpenPlanSidebar, + settings.integratedBrowserUrlPatterns, settings.confirmThreadArchive, settings.confirmThreadDelete, settings.addProjectBaseDirectory, @@ -713,6 +716,7 @@ export function useSettingsRestore(onRestored?: () => void) { addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, + integratedBrowserUrlPatterns: DEFAULT_UNIFIED_SETTINGS.integratedBrowserUrlPatterns, textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans, fontFamilyComposer: DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer, @@ -2027,6 +2031,26 @@ export function GeneralSettingsPanel() { } /> + {isElectron ? ( + 0 ? ( + updateSettings({ integratedBrowserUrlPatterns: [] })} + /> + ) : null + } + > + updateSettings({ integratedBrowserUrlPatterns: next })} + /> + + ) : null} +