feat: add computer control and managed browser

This commit is contained in:
lofyer
2026-08-05 12:55:24 +08:00
parent 2f549387a6
commit 38ac2206f2
92 changed files with 21028 additions and 766 deletions
@@ -0,0 +1,531 @@
import { randomUUID } from 'node:crypto'
import {
BrowserUrlPolicy,
canonicalizeBrowserUrl,
type ValidatedBrowserUrl
} from './browser-url-policy'
import { FilteringProxy } from './filtering-proxy'
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 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<{
toPNG(): Buffer
}>
getURL(): string
stop(): void
close?(options?: { waitForBeforeUnload?: boolean }): void
destroy(): void
isDestroyed(): boolean
}
export type BrowserWindowHandle = {
webContents: BrowserWebContents
loadURL(url: string): Promise<unknown>
destroy(): 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>
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
}
type Listener = {
target: { off(event: string, listener: BrowserEventListener): unknown }
event: string
listener: BrowserEventListener
}
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 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
) {
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({})
)
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,
webPreferences: {
partition,
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
nodeIntegrationInSubFrames: false,
nodeIntegrationInWorker: 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
)
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
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.isApprovedUrl(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.isApprovedUrl(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.isApprovedUrl(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 isApprovedUrl(input: string): boolean {
try {
return (
this.approvedOrigin !== undefined &&
canonicalizeBrowserUrl(input).origin === this.approvedOrigin
)
} 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 {
const origin = canonicalizeBrowserUrl(current).origin
return origin === this.approvedOrigin ? origin : undefined
} catch {
return undefined
}
}
async captureScreenshot(
signal: AbortSignal
): Promise<{
type: 'image'
mimeType: 'image/png'
data: string
}> {
this.assertOpen()
if (!this.webContents.capturePage) {
throw new Error('浏览器原生画面捕获不可用')
}
const image = await boundedSetup(
this.webContents.capturePage(),
signal,
2_000
)
this.assertOpen()
const data = image.toPNG()
if (
data.byteLength < 8 ||
data.byteLength > 5 * 1_024 * 1_024 ||
!data.subarray(0, 8).equals(
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
)
) {
throw new Error('浏览器原生画面无效或过大')
}
return {
type: 'image',
mimeType: 'image/png',
data: data.toString('base64')
}
}
async validateRedirect(url: string, signal: AbortSignal): Promise<void> {
if (!this.approvedOrigin) {
throw new Error('浏览器没有已批准来源')
}
await this.policy.validateRedirect(url, this.approvedOrigin, signal)
}
async dispose(): Promise<void> {
if (this.disposed) {
return
}
this.disposed = true
this.approvedOrigin = undefined
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
)
}
}