Files
goodbuddy/src/main/browser/electron-browser-session.ts
T

671 lines
19 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.
import { randomUUID } from 'node:crypto'
import {
BrowserUrlPolicy,
canonicalizeBrowserUrl,
type ValidatedBrowserUrl
} from './browser-url-policy'
import { FilteringProxy } from './filtering-proxy'
import type { BrowserScreenshot } from './browser-screenshot'
import { encodeBoundedJpeg } from '../bounded-jpeg'
export type BrowserEventListener = (...argumentsValue: never[]) => void
export type BrowserDebugger = {
attach(protocolVersion?: string): void
detach(): void
isAttached(): boolean
sendCommand(
method: string,
commandParams?: Record<string, unknown>
): Promise<unknown>
on(event: string, listener: BrowserEventListener): unknown
off(event: string, listener: BrowserEventListener): unknown
}
export type BrowserCapturedImage = {
getSize(): { width: number; height: number }
resize(options: {
width: number
quality: 'good'
}): BrowserCapturedImage
toJPEG(quality: number): Buffer
}
export type BrowserWebContents = {
debugger: BrowserDebugger
on(event: string, listener: BrowserEventListener): unknown
off(event: string, listener: BrowserEventListener): unknown
setWindowOpenHandler(
handler: (details: { url: string }) => { action: 'deny' }
): void
capturePage?(): Promise<BrowserCapturedImage>
getURL(): string
stop(): void
close?(options?: { waitForBeforeUnload?: boolean }): void
destroy(): void
isDestroyed(): boolean
}
export type BrowserWindowHandle = {
webContents: BrowserWebContents
loadURL(url: string): Promise<unknown>
show(): void
minimize(): void
restore(): void
isMinimized(): boolean
focus(): void
on(event: string, listener: BrowserEventListener): unknown
off(event: string, listener: BrowserEventListener): unknown
destroy(): void
isDestroyed(): boolean
}
export type BrowserParentWindowHandle = {
setEnabled?(enabled: boolean): void
focus?(): void
isDestroyed?(): boolean
}
export type BrowserPartitionSession = {
setPermissionCheckHandler(
handler: (...argumentsValue: never[]) => boolean
): void
setPermissionRequestHandler(
handler: (
webContents: unknown,
permission: string,
callback: (granted: boolean) => void,
details: unknown
) => void
): void
setDisplayMediaRequestHandler(
handler: (
request: unknown,
callback: (streams: Record<string, never>) => void
) => void
): void
setProxy(configuration: {
mode: 'fixed_servers'
proxyRules: string
proxyBypassRules: string
}): Promise<void>
setUserAgent?(
userAgent: string,
acceptLanguages?: string
): void
on(event: string, listener: BrowserEventListener): unknown
off(event: string, listener: BrowserEventListener): unknown
clearData(): Promise<void>
closeAllConnections(): Promise<void>
}
export type FilteringProxyLike = {
start(): Promise<string>
dispose(): Promise<void>
}
export type ElectronBrowserSessionOptions = {
policy: BrowserUrlPolicy
cleanupTimeoutMs?: number
setupTimeoutMs?: number
createPartition?: (partition: string) => Promise<BrowserPartitionSession>
createWindow?: (
options: Record<string, unknown>
) => Promise<BrowserWindowHandle>
createProxy?: (policy: BrowserUrlPolicy) => FilteringProxyLike
parentWindow?: BrowserParentWindowHandle
}
type Listener = {
target: { off(event: string, listener: BrowserEventListener): unknown }
event: string
listener: BrowserEventListener
}
function managedBrowserUserAgent(): string {
const platform =
process.platform === 'win32'
? 'Windows NT 10.0; Win64; x64'
: process.platform === 'darwin'
? 'Macintosh; Intel Mac OS X 10_15_7'
: `X11; Linux ${process.arch === 'arm64' ? 'aarch64' : 'x86_64'}`
return `Mozilla/5.0 (${platform}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${process.versions.chrome ?? '136.0.0.0'} Safari/537.36`
}
async function cleanupIsolatedState(
partitionSession: BrowserPartitionSession | undefined,
proxy: FilteringProxyLike,
timeoutMs: number
): Promise<void> {
const cleanup = Promise.allSettled([
partitionSession?.closeAllConnections() ?? Promise.resolve(),
partitionSession?.clearData() ?? Promise.resolve(),
proxy.dispose()
])
let timer: ReturnType<typeof setTimeout> | undefined
const results = await Promise.race([
cleanup,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(
() => reject(new Error('浏览器隔离数据清理超时')),
timeoutMs
)
})
]).finally(() => {
if (timer) {
clearTimeout(timer)
}
})
const failure = results.find((result) => result.status === 'rejected')
if (failure?.status === 'rejected') {
throw new Error('浏览器隔离数据清理失败', { cause: failure.reason })
}
}
async function boundedSetup<T>(
operation: Promise<T>,
signal: AbortSignal,
timeoutMs: number,
cleanupLateValue?: (value: T) => void | Promise<void>
): Promise<T> {
signal.throwIfAborted()
const timeout = AbortSignal.timeout(timeoutMs)
const effectiveSignal = AbortSignal.any([signal, timeout])
return new Promise<T>((resolve, reject) => {
const abort = (): void => {
reject(
signal.aborted
? signal.reason
: new Error(`浏览器会话创建超时(${timeoutMs}ms`)
)
}
effectiveSignal.addEventListener('abort', abort, { once: true })
void operation.then(
(value) => {
effectiveSignal.removeEventListener('abort', abort)
if (effectiveSignal.aborted) {
try {
void Promise.resolve(cleanupLateValue?.(value)).catch(
() => undefined
)
} catch {
// Cleanup is best-effort after the caller has already timed out.
}
abort()
} else {
resolve(value)
}
},
(error: unknown) => {
effectiveSignal.removeEventListener('abort', abort)
reject(error)
}
)
})
}
async function defaultCreatePartition(
partition: string
): Promise<BrowserPartitionSession> {
const electron = await import('electron')
return electron.session.fromPartition(
partition
) as unknown as BrowserPartitionSession
}
async function defaultCreateWindow(
options: Record<string, unknown>
): Promise<BrowserWindowHandle> {
const electron = await import('electron')
return new electron.BrowserWindow(options) as unknown as BrowserWindowHandle
}
export class ElectronBrowserSession {
readonly partition: string
readonly webContents: BrowserWebContents
private approvedOrigin?: string
private readonly listeners: Listener[] = []
private interaction?: {
promise: Promise<BrowserScreenshot | undefined>
resolve(frame?: BrowserScreenshot): void
}
private interactionClosing?: Promise<void>
private disposed = false
private constructor(
private readonly policy: BrowserUrlPolicy,
private readonly partitionSession: BrowserPartitionSession,
private readonly window: BrowserWindowHandle,
private readonly proxy: FilteringProxyLike,
partition: string,
private readonly cleanupTimeoutMs: number,
private readonly parentWindow?: BrowserParentWindowHandle
) {
this.partition = partition
this.webContents = window.webContents
}
static async create(
options: ElectronBrowserSessionOptions,
signal: AbortSignal = new AbortController().signal
): Promise<ElectronBrowserSession> {
const partition = `browser-${randomUUID()}`
const createPartition = options.createPartition ?? defaultCreatePartition
const createWindow = options.createWindow ?? defaultCreateWindow
const cleanupTimeoutMs = options.cleanupTimeoutMs ?? 5_000
const setupTimeoutMs = options.setupTimeoutMs ?? 15_000
if (!Number.isSafeInteger(cleanupTimeoutMs) || cleanupTimeoutMs < 1) {
throw new Error('浏览器会话清理期限无效')
}
if (!Number.isSafeInteger(setupTimeoutMs) || setupTimeoutMs < 1) {
throw new Error('浏览器会话创建期限无效')
}
const proxy =
options.createProxy?.(options.policy) ??
new FilteringProxy({ policy: options.policy })
let proxyDisposal: Promise<void> | undefined
const managedProxy: FilteringProxyLike = {
start: () => proxy.start(),
dispose: () => {
proxyDisposal ??= proxy.dispose()
return proxyDisposal
}
}
let partitionSession: BrowserPartitionSession | undefined
let window: BrowserWindowHandle | undefined
let result: ElectronBrowserSession | undefined
let setupStage = '启动代理'
try {
const proxyUrl = await boundedSetup(
managedProxy.start(),
signal,
setupTimeoutMs,
async () => managedProxy.dispose()
)
setupStage = '创建隔离会话'
partitionSession = await boundedSetup(
createPartition(partition),
signal,
setupTimeoutMs,
async (latePartition) =>
cleanupIsolatedState(
latePartition,
managedProxy,
cleanupTimeoutMs
)
)
partitionSession.setPermissionCheckHandler(() => false)
partitionSession.setPermissionRequestHandler(
(_contents, _permission, callback) => callback(false)
)
partitionSession.setDisplayMediaRequestHandler(
(_request, callback) => callback({})
)
partitionSession.setUserAgent?.(
managedBrowserUserAgent(),
'zh-CN,zh,en'
)
setupStage = '配置网络代理'
await boundedSetup(
partitionSession.setProxy({
mode: 'fixed_servers',
proxyRules: proxyUrl,
proxyBypassRules: '<-loopback>'
}),
signal,
setupTimeoutMs
)
setupStage = '创建浏览器窗口'
window = await boundedSetup(
createWindow({
show: false,
width: 1280,
height: 900,
title: 'GoodBuddy 浏览器交互',
autoHideMenuBar: true,
...(options.parentWindow
? {
parent: options.parentWindow
}
: {}),
webPreferences: {
partition,
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
nodeIntegrationInSubFrames: false,
nodeIntegrationInWorker: false,
backgroundThrottling: false,
webSecurity: true,
allowRunningInsecureContent: false,
plugins: false,
devTools: false,
safeDialogs: true
}
}),
signal,
setupTimeoutMs,
(lateWindow) => {
if (!lateWindow.isDestroyed()) {
lateWindow.destroy()
} else if (!lateWindow.webContents.isDestroyed()) {
lateWindow.webContents.destroy()
}
}
)
setupStage = '加载初始页面'
await boundedSetup(
window.loadURL('about:blank'),
signal,
setupTimeoutMs
)
result = new ElectronBrowserSession(
options.policy,
partitionSession,
window,
managedProxy,
partition,
cleanupTimeoutMs,
options.parentWindow
)
setupStage = '初始化浏览器协议'
await boundedSetup(result.initialize(), signal, setupTimeoutMs)
return result
} catch (error) {
if (result) {
await result.dispose().catch(() => undefined)
} else if (window && !window.isDestroyed()) {
window.destroy()
await cleanupIsolatedState(
partitionSession,
managedProxy,
cleanupTimeoutMs
).catch(() => undefined)
} else {
await cleanupIsolatedState(
partitionSession,
managedProxy,
cleanupTimeoutMs
).catch(() => undefined)
}
const detail =
error instanceof Error && error.message
? error.message.slice(0, 160)
: '未知错误'
throw new Error(
`无法创建安全浏览器会话:${setupStage}失败(${detail}`,
{ cause: error }
)
}
}
private listen(
target: Listener['target'] & {
on(event: string, listener: BrowserEventListener): unknown
},
event: string,
listener: BrowserEventListener
): void {
target.on(event, listener)
this.listeners.push({ target, event, listener })
}
private async initialize(): Promise<void> {
const contents = this.webContents
this.listen(
this.window,
'close',
(event: { preventDefault(): void }) => {
if (this.disposed) {
return
}
event.preventDefault()
if (this.interaction) {
void this.captureAndFinishInteraction()
} else {
this.window.minimize()
}
}
)
contents.setWindowOpenHandler(() => ({ action: 'deny' }))
this.listen(contents, 'will-navigate', (event: { preventDefault(): void }, details: { url?: string } | string) => {
const url = typeof details === 'string' ? details : details.url
if (!url || !this.updateOriginFromUrl(url)) {
event.preventDefault()
}
})
this.listen(contents, 'will-redirect', (event: { preventDefault(): void }, details: { url?: string } | string) => {
const url = typeof details === 'string' ? details : details.url
if (!url || !this.updateOriginFromUrl(url)) {
event.preventDefault()
}
})
this.listen(contents, 'login', (
event: { preventDefault(): void },
_details: unknown,
_authInfo: unknown,
callback: () => void
) => {
event.preventDefault()
callback()
})
this.listen(contents, 'select-client-certificate', (
event: { preventDefault(): void },
_url: string,
_certificates: unknown[],
callback: (certificate?: unknown) => void
) => {
event.preventDefault()
callback()
})
this.listen(contents, 'did-navigate', (_event: unknown, url: string) => {
if (url && !this.updateOriginFromUrl(url)) {
contents.stop()
}
})
this.listen(
this.partitionSession,
'will-download',
(event: { preventDefault(): void }, item: { cancel?(): void }) => {
event.preventDefault()
item.cancel?.()
}
)
contents.debugger.attach('1.3')
await contents.debugger.sendCommand('Page.enable')
this.assertOpen()
await contents.debugger.sendCommand('Accessibility.enable')
this.assertOpen()
await contents.debugger.sendCommand('Page.setInterceptFileChooserDialog', {
enabled: true
})
this.assertOpen()
this.listen(
contents.debugger,
'message',
(_event: unknown, method: string) => {
if (method === 'Page.fileChooserOpened') {
void contents.debugger
.sendCommand('Page.handleFileChooser', { action: 'cancel' })
.catch(() => undefined)
}
}
)
}
private assertOpen(): void {
if (this.disposed) {
throw new Error('浏览器会话已关闭')
}
}
private updateOriginFromUrl(input: string): boolean {
try {
this.approvedOrigin = canonicalizeBrowserUrl(input).origin
return true
} catch {
return false
}
}
approveNavigation(target: ValidatedBrowserUrl): void {
if (this.disposed) {
throw new Error('浏览器会话已关闭')
}
this.approvedOrigin = target.origin
}
getApprovedOrigin(): string | undefined {
return this.approvedOrigin
}
getCurrentOrigin(): string | undefined {
const current = this.webContents.getURL()
if (!current) {
return undefined
}
try {
return canonicalizeBrowserUrl(current).origin
} catch {
return undefined
}
}
async captureScreenshot(
signal: AbortSignal
): Promise<BrowserScreenshot> {
this.assertOpen()
if (!this.webContents.capturePage) {
throw new Error('浏览器原生画面捕获不可用')
}
const image = await boundedSetup(
this.webContents.capturePage(),
signal,
2_000
)
this.assertOpen()
const data = encodeBoundedJpeg(image)
return {
type: 'image',
mimeType: 'image/jpeg',
data: data.toString('base64')
}
}
async validateRedirect(url: string, signal: AbortSignal): Promise<void> {
const target = await this.policy.validateRedirect(url, signal)
this.approvedOrigin = target.origin
}
openInteraction(): Promise<BrowserScreenshot | undefined> {
this.assertOpen()
if (this.interaction) {
this.setParentEnabled(false)
if (this.window.isMinimized()) {
this.window.restore()
}
this.window.show()
this.window.focus()
return this.interaction.promise
}
let resolve!: (frame?: BrowserScreenshot) => void
const promise = new Promise<BrowserScreenshot | undefined>(
(resolvePromise) => {
resolve = resolvePromise
}
)
this.interaction = { promise, resolve }
this.setParentEnabled(false)
try {
if (this.window.isMinimized()) {
this.window.restore()
}
this.window.show()
this.window.focus()
} catch (error) {
this.finishInteraction()
throw error
}
return promise
}
private captureAndFinishInteraction(): Promise<void> {
if (this.interactionClosing) {
return this.interactionClosing
}
const operation = (async (): Promise<void> => {
let frame: BrowserScreenshot | undefined
try {
frame = await this.captureScreenshot(AbortSignal.timeout(2_000))
} catch {
// The session remains usable even if the final visible frame fails.
}
try {
if (!this.disposed && !this.window.isDestroyed()) {
this.window.minimize()
}
} catch {
// Resolving interaction must not depend on native minimize success.
}
this.finishInteraction(frame)
})()
this.interactionClosing = operation
void operation.finally(() => {
if (this.interactionClosing === operation) {
this.interactionClosing = undefined
}
})
return operation
}
private finishInteraction(frame?: BrowserScreenshot): void {
const interaction = this.interaction
this.interaction = undefined
this.setParentEnabled(true)
interaction?.resolve(frame)
}
private setParentEnabled(enabled: boolean): void {
try {
if (
!this.parentWindow ||
this.parentWindow.isDestroyed?.() === true
) {
return
}
this.parentWindow.setEnabled?.(enabled)
if (enabled) {
this.parentWindow.focus?.()
}
} catch {
// Parent-window state must not break browser-session cleanup.
}
}
async dispose(): Promise<void> {
if (this.disposed) {
return
}
this.disposed = true
this.approvedOrigin = undefined
this.finishInteraction()
for (const { target, event, listener } of this.listeners.splice(0)) {
target.off(event, listener)
}
if (this.webContents.debugger.isAttached()) {
this.webContents.debugger.detach()
}
this.webContents.stop()
if (!this.window.isDestroyed()) {
this.window.destroy()
} else if (!this.webContents.isDestroyed()) {
this.webContents.destroy()
}
await cleanupIsolatedState(
this.partitionSession,
this.proxy,
this.cleanupTimeoutMs
)
}
}