Files
orca/src/shared/browser-url.ts
T
Jinjing 55beeebd25 Allow directly search in google (#13863)
* Allow direct search with configured search engines

Users can now search directly from the tab creation menu using their
configured search engine. Forced search mode (`?` prefix) skips file and tab
matching for guaranteed search. Refactored tab-entry operations into focused
modules for clarity: forced-search parsing, network-safe selection, keyboard
focus, copy strings, empty options, and props types. Search routes through
the same workspace browser tab opening mechanism used for URLs, with safe
title and query presentation that doesn't retain sensitive details.

* Allow direct search with configured search engines

- Permit search and URL navigation while file index loads; require explicit
  selection only when needed, not automatic opening
- Block malformed IPv6 addresses in bracket notation to prevent misclassification
- Support Kagi private-session links via searchUrlOptions
- Improve error handling with accessibility: show error messages in status region,
  disable input during submission, display loading state
- Fall back to local browser tab creation when remote creation fails instead of
  throwing; avoids remote availability blocking local search/navigation
- Add error translations for all supported locales (es, ja, ko, zh)

* Allow direct search with configured search engines

- Extract tab create entry lifecycle to key-driven component remounting, replacing conditional state reset with useEffect cleanup
- Consolidate network tab entry classification and request building into reusable helpers, eliminating duplicate logic
- Simplify owner resolution by inlining logic directly into openWorkspaceBrowserTab
- Replace custom surrogate-pair handling with native String.toWellFormed() for search queries
- Disable explicit URL classification to prioritize search-engine queries over raw URLs

* Allow direct search from quick-open tab bar entry

- Single-token queries keep file matches ranked above search (quick-open intent)
- Multi-word phrases promote search to top, since they cannot be file paths
- Arm network actions once file index fails or text is unambiguous search
- Cache prepared file index to avoid re-processing per keystroke
- Generate specific tab titles (e.g. 'example.com/docs') instead of generic 'Open URL'
- Surface opening workspace when launching browser tabs remotely

* Use readOnly instead of disabled for pending search input

Maintain keyboard focus during submission so arrow/Escape navigation continues to work. Use aria-busy to indicate loading state accessibly. Also fixes button hover styling when disabled and cleans up error message handling in the classifier.

* Treat bare searches as prompts; refine path and IP classification

- Bare search queries (e.g., "?") no longer display as error rows
- Path prefixes with existing matches are no longer blocked mid-keystroke
- Private IPv4 addresses now use http, public addresses use https
- Ambiguous inputs with non-numeric ports fall through to search instead of blocking
- Improve diagnostics by logging failure reasons in openFailure
2026-08-11 17:22:15 -07:00

334 lines
10 KiB
TypeScript

import { ORCA_BROWSER_BLANK_URL } from './constants'
const LOCAL_ADDRESS_PATTERN =
/^(?:localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[[0-9a-f:]+\])(?::\d+)?(?:[/?#].*)?$/i
// Why: bare words like "react hooks" should trigger a search, but inputs that
// look like domain names ("example.com", "foo.bar/path") should navigate directly.
// A single-word input containing a dot with a valid TLD-like suffix is treated as
// a URL attempt, not a search query.
const LOOKS_LIKE_URL_PATTERN = /^[^\s]+\.[a-z]{2,}(\/.*)?$/i
const WINDOWS_ABSOLUTE_PATH_PATTERN = /^[A-Za-z]:[\\/].*$/
const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\s\\/]+[\\/][^\\/]+(?:[\\/].*)?$/
const UNIX_ABSOLUTE_PATH_PATTERN = /^\/.*$/
export type SearchEngine = 'google' | 'duckduckgo' | 'bing' | 'kagi'
export type SearchUrlOptions = {
kagiSessionLink?: string | null
}
export const SEARCH_ENGINE_LABELS: Record<SearchEngine, string> = {
google: 'Google',
duckduckgo: 'DuckDuckGo',
bing: 'Bing',
kagi: 'Kagi'
}
const SEARCH_ENGINE_URLS: Record<SearchEngine, string> = {
google: 'https://www.google.com/search?q=',
duckduckgo: 'https://duckduckgo.com/?q=',
bing: 'https://www.bing.com/search?q=',
kagi: 'https://kagi.com/search?q='
}
export const DEFAULT_SEARCH_ENGINE: SearchEngine = 'google'
export function classifySchemeLessLocalDevAddress(rawInput: string): URL | null {
const trimmed = rawInput.trim()
if (!LOCAL_ADDRESS_PATTERN.test(trimmed)) {
return null
}
try {
return new URL(`http://${trimmed}`)
} catch {
return null
}
}
function normalizeCertificateHostname(hostname: string): string {
const lower = hostname.trim().toLowerCase()
const unbracketed = lower.startsWith('[') && lower.endsWith(']') ? lower.slice(1, -1) : lower
return unbracketed.endsWith('.') ? unbracketed.slice(0, -1) : unbracketed
}
function isValidDnsName(name: string): boolean {
if (name.length === 0 || name.length > 253) {
return false
}
return name
.split('.')
.every(
(label) =>
label.length > 0 && label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label)
)
}
function isIpv4Loopback(hostname: string): boolean {
const octets = hostname.split('.')
if (octets.length !== 4 || octets.some((octet) => !/^\d{1,3}$/.test(octet))) {
return false
}
const values = octets.map(Number)
return (
values[0] === 127 &&
values.every((value, index) => value >= 0 && value <= 255 && octets[index] === String(value))
)
}
export function isEligibleLocalCertificateHost(hostname: string): boolean {
const normalized = normalizeCertificateHostname(hostname)
if (normalized === '::1' || isIpv4Loopback(normalized)) {
return true
}
if (!isValidDnsName(normalized)) {
return false
}
return normalized === 'localhost' || normalized.endsWith('.localhost')
}
function isWildcardBindHost(hostname: string): boolean {
const normalized = normalizeCertificateHostname(hostname)
return normalized === '0.0.0.0' || normalized === '::'
}
export function toHttpsRecoveryUrl(rawUrl: string): string | null {
try {
const parsed = new URL(rawUrl)
if (parsed.protocol !== 'http:' || !isEligibleLocalCertificateHost(parsed.hostname)) {
return null
}
parsed.protocol = 'https:'
return parsed.toString()
} catch {
return null
}
}
export function toSecureCertificateEndpoint(rawUrl: string): string | null {
try {
const parsed = new URL(rawUrl)
if (parsed.protocol !== 'https:' && parsed.protocol !== 'wss:') {
return null
}
const normalizedHostname = normalizeCertificateHostname(parsed.hostname)
if (!normalizedHostname) {
return null
}
const endpointHost = normalizedHostname.includes(':')
? `[${normalizedHostname}]`
: normalizedHostname
return `https://${endpointHost}:${parsed.port || '443'}`
} catch {
return null
}
}
// Why: a remote-owned browser page's loopback URL is unreachable from the
// desktop system browser, so Open Externally is offered only for publicly
// reachable (non-loopback) failure URLs.
export function resolveRemoteFailureExternalUrl(rawUrl: string): string | null {
try {
const parsed = new URL(rawUrl)
if (isWildcardBindHost(parsed.hostname) || isEligibleLocalCertificateHost(parsed.hostname)) {
return null
}
} catch {
return null
}
return normalizeExternalBrowserUrl(rawUrl)
}
export function normalizeKagiSessionLink(rawLink: string): string | null {
const trimmed = rawLink.trim()
if (!trimmed) {
return null
}
try {
const parsed = new URL(trimmed)
const hostname = parsed.hostname.toLowerCase()
const token = parsed.searchParams.get('token')?.trim()
// Why: reject user-info credentials and non-default ports so a hostile
// paste cannot smuggle alternate auth or a redirected origin into the
// saved session URL. Accept /search and /search/ since Kagi's settings
// page emits both.
const pathOk = parsed.pathname === '/search' || parsed.pathname === '/search/'
if (
parsed.protocol !== 'https:' ||
(hostname !== 'kagi.com' && hostname !== 'www.kagi.com') ||
!pathOk ||
parsed.username !== '' ||
parsed.password !== '' ||
parsed.port !== '' ||
!token
) {
return null
}
parsed.searchParams.delete('q')
// Why: collapse any duplicate token params so we don't echo two bearer
// values back to Kagi on every search.
parsed.searchParams.set('token', token)
parsed.hash = ''
return parsed.toString()
} catch {
return null
}
}
export function redactKagiSessionToken(rawUrl: string): string {
try {
const parsed = new URL(rawUrl)
const hostname = parsed.hostname.toLowerCase()
if (
parsed.protocol === 'https:' &&
(hostname === 'kagi.com' || hostname === 'www.kagi.com') &&
(parsed.pathname === '/search' || parsed.pathname === '/search/') &&
parsed.searchParams.has('token')
) {
// Why: Kagi private-session links carry an account bearer token. Strip it
// before URLs reach display, history, or persisted browser-tab state.
parsed.searchParams.delete('token')
return parsed.toString()
}
} catch {
// Keep non-URL inputs unchanged.
}
return rawUrl
}
function buildKagiSessionSearchUrl(
query: string,
sessionLink: string | null | undefined
): string | null {
if (!sessionLink) {
return null
}
const normalized = normalizeKagiSessionLink(sessionLink)
if (!normalized) {
return null
}
const parsed = new URL(normalized)
parsed.searchParams.set('q', query)
return parsed.toString()
}
export function buildSearchUrl(
query: string,
engine: SearchEngine = DEFAULT_SEARCH_ENGINE,
options: SearchUrlOptions = {}
): string {
if (engine === 'kagi') {
const sessionSearchUrl = buildKagiSessionSearchUrl(query, options.kagiSessionLink)
if (sessionSearchUrl) {
return sessionSearchUrl
}
}
return `${SEARCH_ENGINE_URLS[engine]}${encodeURIComponent(query.toWellFormed())}`
}
export function looksLikeSearchQuery(input: string): boolean {
if (input.includes(' ')) {
return true
}
if (LOOKS_LIKE_URL_PATTERN.test(input)) {
return false
}
if (input.includes('.') || input.includes(':')) {
return false
}
return true
}
function absolutePathToFileUrl(filePath: string): string {
const normalizedPath = filePath.replaceAll('\\', '/')
const segments = normalizedPath.split('/').map((segment, index) => {
if (index === 0 && /^[A-Za-z]:$/.test(segment)) {
return segment
}
return encodeURIComponent(segment)
})
return normalizedPath.startsWith('/')
? `file://${segments.join('/')}`
: `file:///${segments.join('/')}`
}
function windowsUncPathToFileUrl(filePath: string): string {
const normalizedPath = filePath.replaceAll('\\', '/').replace(/^\/+/, '')
const [host, ...pathSegments] = normalizedPath.split('/')
return `file://${host}/${pathSegments.map(encodeURIComponent).join('/')}`
}
export function normalizeBrowserNavigationUrl(
rawUrl: string,
searchEngine?: SearchEngine | null,
options: SearchUrlOptions = {}
): string | null {
const trimmed = rawUrl.trim()
if (trimmed.length === 0 || trimmed === 'about:blank' || trimmed === ORCA_BROWSER_BLANK_URL) {
return ORCA_BROWSER_BLANK_URL
}
const localDevAddress = classifySchemeLessLocalDevAddress(trimmed)
if (localDevAddress) {
return localDevAddress.toString()
}
if (WINDOWS_UNC_PATH_PATTERN.test(trimmed)) {
return windowsUncPathToFileUrl(trimmed)
}
if (UNIX_ABSOLUTE_PATH_PATTERN.test(trimmed) || WINDOWS_ABSOLUTE_PATH_PATTERN.test(trimmed)) {
return absolutePathToFileUrl(trimmed)
}
try {
const parsed = new URL(trimmed)
// Why: file:// is allowed so the browser pane can render local files the
// user already has access to via the editor (e.g. "Open Preview to the
// Side" on an HTML file). The guest webview is still sandboxed
// (nodeIntegration off, contextIsolation on, webSecurity on; see
// createMainWindow.ts will-attach-webview), so the loaded page cannot
// escalate privileges. Other non-web schemes (javascript:, arbitrary
// data: URIs) remain rejected.
return parsed.protocol === 'http:' ||
parsed.protocol === 'https:' ||
parsed.protocol === 'file:'
? parsed.toString()
: null
} catch {
// Why: search fallback is opt-in. The main process calls this function for
// URL validation (will-attach-webview, will-navigate) where non-URL text
// must be rejected, not converted to a search query. Only the address bar
// passes a search engine to enable the fallback.
const searchEnabled = searchEngine !== undefined
try {
const withScheme = new URL(`https://${trimmed}`)
if (!searchEnabled || !looksLikeSearchQuery(trimmed)) {
return withScheme.toString()
}
} catch {
// Not a valid URL even with https:// prefix
}
if (!searchEnabled) {
return null
}
return buildSearchUrl(trimmed, searchEngine ?? DEFAULT_SEARCH_ENGINE, options)
}
}
export function normalizeExternalBrowserUrl(rawUrl: string): string | null {
const normalized = normalizeBrowserNavigationUrl(rawUrl)
if (normalized === null || normalized === ORCA_BROWSER_BLANK_URL) {
return null
}
// Why: external-link opening (shell.openExternal, will-navigate) must only
// hand off http(s) targets to the OS. file:// is allowed for the in-app
// browser pane (local HTML preview), but forwarding it to openExternal
// would let a remote page smuggle arbitrary file paths into Finder/Explorer.
if (normalized.startsWith('file:')) {
return null
}
return normalized
}