Files
inkreach-agent/apps/desktop/src/main/browser/browser-policy.ts
T

124 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 受管浏览器的 URL 与观察策略。
* 独立成模块便于单测与复用(渲染层不需要,主进程专用)。
*/
const SEARCH_URL = 'https://www.google.com/search?q='
/** 导航白名单:普通 web、本地开发服务与 about:blank;file:/javascript:/data: 一律拒绝 */
export function assertSafeBrowserUrl(input: string): string {
const value = input.trim()
if (!value) throw new Error('URL 不能为空。')
if (value === 'about:blank') return value
let parsed: URL
try {
parsed = new URL(value)
} catch {
throw new Error(`无法解析的 URL: ${value}`)
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`不支持的地址协议(仅允许 http/https 与 about:blank): ${parsed.protocol}`)
}
return parsed.toString()
}
function isLikelyUrl(value: string): boolean {
const candidate = `http://${value}`
try {
const url = new URL(candidate)
const host = url.hostname.toLowerCase()
// 域名 / IP(v4/v6)/ localhost 与 *.localhost 视为直达地址
const isHost =
host === 'localhost' ||
host.endsWith('.localhost') ||
host.includes('.') ||
host.includes(':')
// 无空格且形如域名/IP/localhost
return isHost && !/\s/.test(value)
} catch {
return false
}
}
/** 输入归一化:URL/域名/IP/localhost 直达,普通文本走搜索 */
export function resolveBrowserDestination(input: string): string {
const value = input.trim()
if (!value) throw new Error('地址不能为空。')
if (value === 'about:blank') return value
if (/^https?:\/\//i.test(value)) return assertSafeBrowserUrl(value)
if (isLikelyUrl(value)) return assertSafeBrowserUrl(`https://${value}`)
return SEARCH_URL + encodeURIComponent(value)
}
// ---- Observe 观察策略 ----
export const DEFAULT_BROWSER_OBSERVE_MAX_ELEMENTS = 240
export const MAX_BROWSER_OBSERVE_MAX_ELEMENTS = 400
export const MIN_BROWSER_OBSERVE_MAX_ELEMENTS = 20
/** 可交互 AX role 白名单(占观察结果前 2/3 配额) */
export const INTERACTIVE_AX_ROLES = new Set([
'button',
'link',
'textfield',
'textbox',
'searchbox',
'checkbox',
'radiobutton',
'combobox',
'menuitem',
'menuitemcheckbox',
'menuitemradio',
'tab',
'option',
'switch',
'slider'
])
/** 无名称也值得返回的 role(依赖 role 本身可定位) */
export const NAMELESS_ALLOWED_ROLES = new Set(['button', 'textfield', 'textbox', 'link', 'checkbox', 'combobox'])
/** browser_find_text 单次返回的候选上限(DOM 直查不受快照配额约束,巨页防爆量) */
export const MAX_BROWSER_FIND_TEXT_RESULTS = 40
export function resolveBrowserObserveMaxElements(requested?: number): number {
if (typeof requested !== 'number' || !Number.isFinite(requested)) return DEFAULT_BROWSER_OBSERVE_MAX_ELEMENTS
return Math.min(MAX_BROWSER_OBSERVE_MAX_ELEMENTS, Math.max(MIN_BROWSER_OBSERVE_MAX_ELEMENTS, Math.round(requested)))
}
export function isInteractiveAxRole(role: string): boolean {
return INTERACTIVE_AX_ROLES.has(role.toLowerCase())
}
export interface BrowserObservationCandidate {
role: string
name: string
editable?: boolean
}
/** 可交互元素优先(约 2/3 配额),其余语义上下文补足 */
export function prioritizeBrowserObservationCandidates<T extends BrowserObservationCandidate>(
candidates: readonly T[],
maxElements: number
): T[] {
const interactiveLimit = Math.ceil(maxElements * (2 / 3))
const interactive: T[] = []
const context: T[] = []
for (const candidate of candidates) {
if (interactive.length < interactiveLimit && (isInteractiveAxRole(candidate.role) || candidate.editable)) {
interactive.push(candidate)
} else {
context.push(candidate)
}
}
const contextLimit = maxElements - interactive.length
return [...interactive, ...context.slice(0, Math.max(0, contextLimit))]
}
/** 不同 role 的可访问名称截断上限,避免长文本撑爆快照 */
export function browserObservationNameLimit(role: string): number {
if (role === 'link' || role === 'button' || role === 'menuitem') return 80
if (role === 'heading') return 120
return 160
}