Validate rejected browser address schemes

This commit is contained in:
Neil
2026-05-30 03:12:15 -07:00
parent 1dfa5c4aee
commit 4a334060d2
3 changed files with 209 additions and 105 deletions
@@ -4,25 +4,8 @@ import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command'
import { useAppStore } from '@/store'
import {
buildSearchUrl,
looksLikeSearchQuery,
normalizeBrowserNavigationUrl,
SEARCH_ENGINE_LABELS,
DEFAULT_SEARCH_ENGINE,
type SearchEngine
} from '../../../../shared/browser-url'
const MAX_SUGGESTIONS = 8
type SuggestionEntry = {
url: string
title: string
subtitle: string
lastVisitedAt: number
visitCount: number
isSearch: boolean
}
import { DEFAULT_SEARCH_ENGINE, type SearchEngine } from '../../../../shared/browser-url'
import { buildBrowserAddressBarSuggestions } from './browser-address-bar-suggestions'
type BrowserAddressBarProps = {
value: string
@@ -32,28 +15,6 @@ type BrowserAddressBarProps = {
inputRef: React.RefObject<HTMLInputElement | null>
}
function scoreSuggestion(
entry: { url: string; title: string; lastVisitedAt: number; visitCount: number },
query: string
): number {
const lowerQuery = query.toLowerCase()
const lowerUrl = entry.url.toLowerCase()
const lowerTitle = entry.title.toLowerCase()
if (!lowerUrl.includes(lowerQuery) && !lowerTitle.includes(lowerQuery)) {
return -1
}
let score = 0
if (lowerUrl.startsWith(lowerQuery) || lowerUrl.startsWith(`https://${lowerQuery}`)) {
score += 100
}
score += Math.min(entry.visitCount, 50)
const ageHours = (Date.now() - entry.lastVisitedAt) / (1000 * 60 * 60)
score += Math.max(0, 24 - ageHours)
return score
}
export default function BrowserAddressBar({
value,
onChange,
@@ -94,70 +55,16 @@ export default function BrowserAddressBar({
const searchEngine: SearchEngine =
(browserDefaultSearchEngine as SearchEngine | null) ?? DEFAULT_SEARCH_ENGINE
const suggestions = useMemo((): SuggestionEntry[] => {
const trimmed = value.trim()
if (trimmed === '' || trimmed === 'about:blank' || trimmed.startsWith('data:')) {
if (browserUrlHistory.length === 0) {
return []
}
return [...browserUrlHistory]
.sort((a, b) => b.lastVisitedAt - a.lastVisitedAt)
.slice(0, MAX_SUGGESTIONS)
.map((entry) => ({ ...entry, subtitle: entry.url, isSearch: false }))
}
const historySuggestions: SuggestionEntry[] =
browserUrlHistory.length > 0
? browserUrlHistory
.map((entry) => ({ entry, score: scoreSuggestion(entry, trimmed) }))
.filter((item) => item.score >= 0)
.sort((a, b) => b.score - a.score)
.slice(0, MAX_SUGGESTIONS - 1)
.map((item) => ({ ...item.entry, subtitle: item.entry.url, isSearch: false }))
: []
// Why: the top row of the dropdown must always mirror what pressing Enter
// will do — i.e. what `normalizeBrowserNavigationUrl` resolves to. Chrome
// and Firefox omniboxes work this way: for URL-like inputs the top row is
// the typed URL itself (navigate), and for bare queries it is the search.
// The earlier implementation appended a "Google Search" row as a fallback
// for URL-like inputs, which then got auto-selected when no history
// matched — so Enter on "www.example.com" hit Google instead of the site.
const isQuery = looksLikeSearchQuery(trimmed)
const topAction: SuggestionEntry = isQuery
? {
url: buildSearchUrl(trimmed, searchEngine, {
kagiSessionLink: browserKagiSessionLink
}),
title: trimmed,
subtitle: `${SEARCH_ENGINE_LABELS[searchEngine]} Search`,
lastVisitedAt: 0,
visitCount: 0,
isSearch: true
}
: {
url:
normalizeBrowserNavigationUrl(trimmed, searchEngine, {
kagiSessionLink: browserKagiSessionLink
}) ?? trimmed,
title: trimmed,
subtitle: '',
lastVisitedAt: 0,
visitCount: 0,
isSearch: false
}
// Why: if a history row already targets the same URL as the top action,
// skip the synthetic top row — the history row is more informative (real
// page title) and will be auto-selected, so Enter still navigates to the
// same place.
const duplicateIdx = historySuggestions.findIndex((h) => h.url === topAction.url)
if (duplicateIdx >= 0) {
return historySuggestions.slice(0, MAX_SUGGESTIONS)
}
return [topAction, ...historySuggestions].slice(0, MAX_SUGGESTIONS)
}, [browserUrlHistory, value, searchEngine, browserKagiSessionLink])
const suggestions = useMemo(
() =>
buildBrowserAddressBarSuggestions({
browserUrlHistory,
kagiSessionLink: browserKagiSessionLink,
searchEngine,
value
}),
[browserUrlHistory, value, searchEngine, browserKagiSessionLink]
)
const selectedValue =
selectedValueOverride &&
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import type { BrowserHistoryEntry } from '../../../../shared/types'
import { buildBrowserAddressBarSuggestions } from './browser-address-bar-suggestions'
function historyEntry(overrides: Partial<BrowserHistoryEntry>): BrowserHistoryEntry {
return {
url: 'https://example.com/',
normalizedUrl: 'https://example.com',
title: 'Example',
lastVisitedAt: 1_700_000_000_000,
visitCount: 1,
...overrides
}
}
describe('browser address bar suggestions', () => {
it('keeps the most recent history suggestions for blank input', () => {
const suggestions = buildBrowserAddressBarSuggestions({
value: '',
browserUrlHistory: [
historyEntry({
url: 'https://old.example.com/',
normalizedUrl: 'https://old.example.com',
title: 'Old',
lastVisitedAt: 1
}),
historyEntry({
url: 'https://new.example.com/',
normalizedUrl: 'https://new.example.com',
title: 'New',
lastVisitedAt: 2
})
]
})
expect(suggestions.map((suggestion) => suggestion.url)).toEqual([
'https://new.example.com/',
'https://old.example.com/'
])
})
it('puts the search action first for bare query input', () => {
const suggestions = buildBrowserAddressBarSuggestions({
value: 'react hooks',
browserUrlHistory: [],
searchEngine: 'duckduckgo'
})
expect(suggestions[0]).toMatchObject({
url: 'https://duckduckgo.com/?q=react%20hooks',
title: 'react hooks',
subtitle: 'DuckDuckGo Search',
isSearch: true
})
})
it('puts URL-like navigation first when normalization succeeds', () => {
const suggestions = buildBrowserAddressBarSuggestions({
value: 'example.com',
browserUrlHistory: []
})
expect(suggestions[0]).toMatchObject({
url: 'https://example.com/',
title: 'example.com',
isSearch: false
})
})
it('does not turn a rejected scheme into a selectable navigation row', () => {
const suggestions = buildBrowserAddressBarSuggestions({
value: 'javascript:alert(1)',
browserUrlHistory: []
})
expect(suggestions).toEqual([])
})
})
@@ -0,0 +1,119 @@
import {
buildSearchUrl,
DEFAULT_SEARCH_ENGINE,
looksLikeSearchQuery,
normalizeBrowserNavigationUrl,
SEARCH_ENGINE_LABELS,
type SearchEngine
} from '../../../../shared/browser-url'
import type { BrowserHistoryEntry } from '../../../../shared/types'
export const MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS = 8
export type BrowserAddressBarSuggestion = {
url: string
title: string
subtitle: string
lastVisitedAt: number
visitCount: number
isSearch: boolean
}
function scoreBrowserAddressBarSuggestion(
entry: { url: string; title: string; lastVisitedAt: number; visitCount: number },
query: string
): number {
const lowerQuery = query.toLowerCase()
const lowerUrl = entry.url.toLowerCase()
const lowerTitle = entry.title.toLowerCase()
if (!lowerUrl.includes(lowerQuery) && !lowerTitle.includes(lowerQuery)) {
return -1
}
let score = 0
if (lowerUrl.startsWith(lowerQuery) || lowerUrl.startsWith(`https://${lowerQuery}`)) {
score += 100
}
score += Math.min(entry.visitCount, 50)
const ageHours = (Date.now() - entry.lastVisitedAt) / (1000 * 60 * 60)
score += Math.max(0, 24 - ageHours)
return score
}
export function buildBrowserAddressBarSuggestions({
browserUrlHistory,
kagiSessionLink,
searchEngine = DEFAULT_SEARCH_ENGINE,
value
}: {
browserUrlHistory: readonly BrowserHistoryEntry[]
kagiSessionLink?: string | null
searchEngine?: SearchEngine
value: string
}): BrowserAddressBarSuggestion[] {
const trimmed = value.trim()
if (trimmed === '' || trimmed === 'about:blank' || trimmed.startsWith('data:')) {
if (browserUrlHistory.length === 0) {
return []
}
return [...browserUrlHistory]
.sort((a, b) => b.lastVisitedAt - a.lastVisitedAt)
.slice(0, MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS)
.map((entry) => ({ ...entry, subtitle: entry.url, isSearch: false }))
}
const historySuggestions: BrowserAddressBarSuggestion[] =
browserUrlHistory.length > 0
? browserUrlHistory
.map((entry) => ({
entry,
score: scoreBrowserAddressBarSuggestion(entry, trimmed)
}))
.filter((item) => item.score >= 0)
.sort((a, b) => b.score - a.score)
.slice(0, MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS - 1)
.map((item) => ({ ...item.entry, subtitle: item.entry.url, isSearch: false }))
: []
const isQuery = looksLikeSearchQuery(trimmed)
let topAction: BrowserAddressBarSuggestion | null
if (isQuery) {
topAction = {
url: buildSearchUrl(trimmed, searchEngine, { kagiSessionLink }),
title: trimmed,
subtitle: `${SEARCH_ENGINE_LABELS[searchEngine]} Search`,
lastVisitedAt: 0,
visitCount: 0,
isSearch: true
}
} else {
const normalizedUrl = normalizeBrowserNavigationUrl(trimmed, searchEngine, {
kagiSessionLink
})
// Why: rejected schemes must use the submit path's validation error;
// a synthetic row would pass the raw string straight to webview.src.
topAction = normalizedUrl
? {
url: normalizedUrl,
title: trimmed,
subtitle: '',
lastVisitedAt: 0,
visitCount: 0,
isSearch: false
}
: null
}
if (!topAction) {
return historySuggestions.slice(0, MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS)
}
// Why: the history row gives Enter the same target while showing real page metadata.
const duplicateIdx = historySuggestions.findIndex((h) => h.url === topAction.url)
if (duplicateIdx >= 0) {
return historySuggestions.slice(0, MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS)
}
return [topAction, ...historySuggestions].slice(0, MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS)
}