mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 16:02:55 +00:00
Validate rejected browser address schemes
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user