feat(browser): 受管浏览器代理设置(profile 导入已有实现,本轮补全代理)

This commit is contained in:
Rick Ye
2026-09-11 17:02:11 +08:00
parent 6507948f01
commit b6e6031415
7 changed files with 263 additions and 7 deletions
@@ -4,7 +4,7 @@ const log = getLogger('browser')
import { app, BrowserWindow, session as electronSession, WebContentsView, type Rectangle, type Session } from 'electron'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import type { BrowserImportInfo, BrowserTabInfo, BrowserUiState } from '@shared/types'
import type { BrowserImportInfo, BrowserProxySettings, BrowserTabInfo, BrowserUiState } from '@shared/types'
import {
BrowserCdpTimeoutError,
BROWSER_OBSERVE_TIMEOUT_MS,
@@ -32,6 +32,9 @@ import {
* + Input.dispatch* 真实输入事件;不在页面里执行模拟点击。
* - 登录态隔离在 persist:aso-browser 分区;本地浏览器配置导入由 browser-profile.ts
* 直接迁移分区目录数据(Cookies/Local Storage + 密钥移植)。
* - 代理设置作用于同一分区会话(session.setProxy):跟随系统 / 直连 / 手动规则
* (Chromium proxyRules 语法 + 绕过列表),持久化进 aso-browser.json,
* 启动即应用、修改后断开在途连接立即生效(对 Agent 的 browser_* 工具同样生效)。
*/
const PARTITION = 'persist:aso-browser'
@@ -71,6 +74,7 @@ interface TabRecord {
interface BrowserSettings {
riskAcknowledged: boolean
imported?: BrowserImportInfo | null
proxy?: BrowserProxySettings
}
export interface BrowserObservation {
@@ -111,6 +115,36 @@ function textValue(v: unknown): string {
return ''
}
/** 校验/规范化代理输入(fixed 模式必须有可解析的 host:port 规则;其余模式丢弃规则字段) */
function normalizeProxy(input: BrowserProxySettings): BrowserProxySettings {
if (input.mode !== 'fixed') return { mode: input.mode }
const entries = (input.rules ?? '')
.split(';')
.map((s) => s.trim())
.filter(Boolean)
if (entries.length === 0) {
throw new Error('手动代理需要填写规则,如 127.0.0.1:7890(多协议用分号分隔,如 http=127.0.0.1:7890;https=127.0.0.1:7890)。')
}
for (const entry of entries) {
const hostPort = entry.includes('://')
? entry.slice(entry.indexOf('://') + 3)
: entry.includes('=')
? entry.slice(entry.indexOf('=') + 1)
: entry
const colon = hostPort.lastIndexOf(':')
const port = colon >= 0 ? hostPort.slice(colon + 1) : ''
if (!hostPort || colon < 0 || !/^\d{1,5}$/.test(port) || Number(port) < 1 || Number(port) > 65535) {
throw new Error(`代理规则「${entry}」无效:应为 host:port(如 127.0.0.1:7890)。`)
}
}
const bypass = (input.bypass ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.join(',')
return { mode: 'fixed', rules: entries.join(';'), bypass: bypass || undefined }
}
function isEditableAxNode(node: Record<string, unknown>): boolean {
const role = textValue(node.role).toLowerCase()
if (role === 'textfield' || role === 'textbox' || role === 'searchbox') return true
@@ -310,6 +344,10 @@ export class BrowserController {
item.setSavePath(target)
})
this.settings = this.loadSettings()
// 启动即应用持久化的代理设置(setProxy 异步;失败不阻塞浏览器初始化,日志可查)
this.applyProxy(this.proxySettings()).catch((err: unknown) => {
log.error('启动时代理设置应用失败:', { err })
})
}
// ---- 设置持久化 ----
@@ -321,12 +359,17 @@ export class BrowserController {
private loadSettings(): BrowserSettings {
try {
const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as Partial<BrowserSettings>
const proxy = raw.proxy
return {
riskAcknowledged: raw.riskAcknowledged === true,
imported: raw.imported ?? null
imported: raw.imported ?? null,
proxy:
proxy && (proxy.mode === 'direct' || proxy.mode === 'fixed' || proxy.mode === 'system')
? proxy
: { mode: 'system' }
}
} catch {
return { riskAcknowledged: false }
return { riskAcknowledged: false, proxy: { mode: 'system' } }
}
}
@@ -349,6 +392,38 @@ export class BrowserController {
return this.settings.imported ?? null
}
// ---- 代理设置 ----
private proxySettings(): BrowserProxySettings {
return this.settings.proxy ?? { mode: 'system' }
}
getProxySettings(): BrowserProxySettings {
return this.proxySettings()
}
/** 应用到分区会话;切换后断开在途连接,让新规则对已打开的标签立即生效 */
private async applyProxy(proxy: BrowserProxySettings): Promise<void> {
await this.browserSession.setProxy(
proxy.mode === 'fixed'
? { mode: 'fixed_servers', proxyRules: proxy.rules ?? '', proxyBypassRules: proxy.bypass }
: proxy.mode === 'direct'
? { mode: 'direct' }
: { mode: 'system' }
)
await this.browserSession.closeAllConnections()
}
/** 更新并持久化代理设置:先应用成功才落盘(失败抛错给 UI,原设置保持不变) */
async setProxySettings(input: BrowserProxySettings): Promise<BrowserProxySettings> {
const next = normalizeProxy(input)
await this.applyProxy(next)
this.settings.proxy = next
this.saveSettings()
this.emitState()
return next
}
/** 当前是否有活跃标签页(导入配置前必须没有) */
get hasTabs(): boolean {
return this.tabs.size > 0
@@ -395,7 +470,8 @@ export class BrowserController {
activeTabId: this.activeTabId,
recentClosed: this.closedTabs.map(({ title, url }) => ({ title, url })),
trace: this.traceText,
imported: this.settings.imported ?? null
imported: this.settings.imported ?? null,
proxy: this.proxySettings()
}
}
+11
View File
@@ -409,6 +409,17 @@ export function registerIpc(): AgentService {
return browserPass(() => importBrowserProfile(sourceId, profileDir))
})
// ---- 受管浏览器代理设置(persist:aso-browser 分区,保存即生效) ----
handle('browser:getProxy', () => getBrowserController().getProxySettings())
handle('browser:setProxy', (_e, input: unknown) => {
if (!input || typeof input !== 'object') throw new Error('invalid proxy settings')
const { mode, rules, bypass } = input as Record<string, unknown>
if (mode !== 'system' && mode !== 'direct' && mode !== 'fixed') throw new Error('invalid proxy mode')
if (rules !== undefined && typeof rules !== 'string') throw new Error('invalid proxy rules')
if (bypass !== undefined && typeof bypass !== 'string') throw new Error('invalid proxy bypass')
return getBrowserController().setProxySettings({ mode, rules, bypass })
})
// ---- 插件系统 ----
void app.whenReady().then(() => {
// 插件启停/重载会重建 agent 运行时(extensionFactories 重跑)
+4
View File
@@ -4,6 +4,7 @@ import type {
AppSettings,
AttachmentInfo,
BrowserImportResult,
BrowserProxySettings,
BrowserSource,
BrowserUiEvent,
BrowserUiState,
@@ -129,6 +130,9 @@ const api = {
browserDetectSources: (): Promise<BrowserSource[]> => ipcRenderer.invoke('browser:detectSources'),
browserImportProfile: (sourceId: string, profileDir: string): Promise<BrowserImportResult> =>
ipcRenderer.invoke('browser:importProfile', sourceId, profileDir),
browserGetProxy: (): Promise<BrowserProxySettings> => ipcRenderer.invoke('browser:getProxy'),
browserSetProxy: (input: BrowserProxySettings): Promise<BrowserProxySettings> =>
ipcRenderer.invoke('browser:setProxy', input),
onBrowserEvent: (callback: (event: BrowserUiEvent) => void): (() => void) => {
const listener = (_e: Electron.IpcRendererEvent, event: BrowserUiEvent): void => callback(event)
ipcRenderer.on('browser:event', listener)
@@ -6,11 +6,12 @@ import {
LuCircleAlert,
LuDownload,
LuGlobe,
LuNetwork,
LuPlus,
LuRotateCw,
LuX
} from 'react-icons/lu'
import type { BrowserImportResult, BrowserSource, BrowserUiState } from '@shared/types'
import type { BrowserImportResult, BrowserProxySettings, BrowserSource, BrowserUiState } from '@shared/types'
import { BrowserSlot } from './BrowserSlot'
/**
@@ -43,6 +44,7 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
const { state, suspended } = props
const [address, setAddress] = useState('')
const [importOpen, setImportOpen] = useState(false)
const [proxyOpen, setProxyOpen] = useState(false)
const [tabListOpen, setTabListOpen] = useState(false)
const [tabMenu, setTabMenu] = useState<{ x: number; y: number; tabId: string } | null>(null)
@@ -80,7 +82,7 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
const riskBanner = state && !state.riskAcknowledged
// 下拉/右键菜单会伸到页面区域上方,原生视图必须暂时让位(原生层级永远在 DOM 之上)
const overlayOpen = importOpen || tabListOpen || tabMenu !== null
const overlayOpen = importOpen || proxyOpen || tabListOpen || tabMenu !== null
// 需要暂时隐藏原生视图的原因(宿主全屏浮层 + 本面板菜单)
const hideCause = suspended || overlayOpen
@@ -362,6 +364,15 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
>
<LuDownload size={13} />
</button>
<button
type="button"
onClick={() => setProxyOpen(true)}
title="代理设置"
aria-expanded={proxyOpen}
className="flex size-7 items-center justify-center rounded text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
>
<LuNetwork size={13} />
</button>
</div>
{/* 风险告知(首次使用门禁,Agent 与用户操作都会被拦截直到确认) */}
@@ -419,6 +430,9 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
onImportProfile={props.onImportProfile}
/>
)}
{/* 代理设置浮层(覆盖面板,出现时原生视图已隐藏) */}
{proxyOpen && <ProxyOverlay state={state} onClose={() => setProxyOpen(false)} />}
</div>
)
}
@@ -560,3 +574,139 @@ function ImportOverlay({
</div>
)
}
// ---- 代理设置浮层 ----
const PROXY_MODES: Array<{ id: BrowserProxySettings['mode']; label: string }> = [
{ id: 'system', label: '跟随系统' },
{ id: 'direct', label: '直连' },
{ id: 'fixed', label: '手动代理' }
]
function ProxyOverlay({ state, onClose }: { state: BrowserUiState | null; onClose: () => void }): JSX.Element {
const current = state?.proxy ?? { mode: 'system' as const }
const [mode, setMode] = useState<BrowserProxySettings['mode']>(current.mode)
const [rules, setRules] = useState(current.mode === 'fixed' ? (current.rules ?? '') : '')
const [bypass, setBypass] = useState(current.bypass ?? '')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [saved, setSaved] = useState(false)
// 主进程广播回来(保存成功/其他入口改动)→ 表单跟随当前生效值
useEffect(() => {
setMode(current.mode)
setRules(current.mode === 'fixed' ? (current.rules ?? '') : '')
setBypass(current.bypass ?? '')
}, [current.mode, current.rules, current.bypass])
const save = (): void => {
setBusy(true)
setError(null)
setSaved(false)
window.api
.browserSetProxy({ mode, rules: rules.trim(), bypass: bypass.trim() })
.then((p) => {
setMode(p.mode)
setRules(p.rules ?? '')
setBypass(p.bypass ?? '')
setSaved(true)
})
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
.finally(() => setBusy(false))
}
const inputCls =
'w-full rounded border border-line bg-inset px-2.5 py-1.5 text-[12px] text-ink outline-none placeholder:text-ink-3 focus:border-accent focus:bg-surface'
return (
<div className="absolute inset-0 z-20 flex flex-col bg-surface/95 backdrop-blur-[2px]">
<div className="flex h-10 shrink-0 items-center justify-between border-b border-line px-3">
<div className="text-[12.5px] font-medium text-ink-2">代理设置</div>
<button
type="button"
aria-label="关闭"
onClick={onClose}
className="flex size-6 items-center justify-center rounded text-ink-3 hover:bg-hover hover:text-ink-2"
>
<LuX size={13} />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3 text-[12px] text-ink-2">
<p className="mb-3 leading-relaxed text-ink-3">
代理只作用于内置受管浏览器(不影响应用其他部分与系统设置),保存后立即生效。
</p>
<div className="flex w-fit items-center rounded-control border border-line bg-hover p-0.5">
{PROXY_MODES.map((m) => (
<button
key={m.id}
type="button"
onClick={() => setMode(m.id)}
className={`h-7 rounded-[6px] px-3 text-[12px] transition-colors ${
mode === m.id ? 'bg-surface font-medium text-ink shadow-card' : 'text-ink-3 hover:text-ink-2'
}`}
>
{m.label}
</button>
))}
</div>
{mode === 'fixed' && (
<div className="mt-3 flex flex-col gap-2.5">
<label className="flex flex-col gap-1">
<span className="text-[11px] text-ink-3">代理规则(多协议用分号分隔)</span>
<input
value={rules}
onChange={(e) => setRules(e.target.value)}
placeholder="127.0.0.1:7890 或 http=127.0.0.1:7890;https=127.0.0.1:7890"
spellCheck={false}
className={inputCls}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-[11px] text-ink-3">绕过列表(逗号分隔,可留空)</span>
<input
value={bypass}
onChange={(e) => setBypass(e.target.value)}
placeholder="<local>,*.example.com,127.0.0.1"
spellCheck={false}
className={inputCls}
/>
</label>
<p className="text-[10.5px] leading-4 text-ink-3">
规则语法与 Chromium 一致:host:port 应用于全部协议;也可按协议指定(http=…;https=…;socks=…)。
</p>
</div>
)}
{error && (
<div className="mt-3 rounded border border-red/40 bg-red-tint px-2.5 py-2 text-[11.5px] text-red">{error}</div>
)}
{saved && !error && (
<div className="mt-3 rounded border border-green-200 bg-green-tint px-2.5 py-2 text-[11.5px] text-green">
已保存并立即生效(在途连接已重置)。
</div>
)}
</div>
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-line px-3 py-2.5">
<button
type="button"
onClick={onClose}
className="rounded px-3 py-1.5 text-[12px] text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
>
关闭
</button>
<button
type="button"
disabled={busy || (mode === 'fixed' && !rules.trim())}
onClick={save}
className="rounded bg-accent px-3.5 py-1.5 text-[12px] font-medium text-page transition-opacity hover:opacity-90 disabled:opacity-40"
>
{busy ? '应用中…' : '保存并应用'}
</button>
</div>
</div>
)
}
+3
View File
@@ -3,6 +3,7 @@ import type {
AppSettings,
AttachmentInfo,
BrowserImportResult,
BrowserProxySettings,
BrowserSource,
BrowserUiEvent,
BrowserUiState,
@@ -115,6 +116,8 @@ declare global {
}) => Promise<void>
browserDetectSources: () => Promise<BrowserSource[]>
browserImportProfile: (sourceId: string, profileDir: string) => Promise<BrowserImportResult>
browserGetProxy: () => Promise<BrowserProxySettings>
browserSetProxy: (input: BrowserProxySettings) => Promise<BrowserProxySettings>
onBrowserEvent: (callback: (event: BrowserUiEvent) => void) => () => void
// ---- 插件系统 ----
pluginsList: () => Promise<PluginInfo[]>
+12
View File
@@ -294,6 +294,18 @@ export interface BrowserUiState {
/** 最近一次 Agent 浏览器操作描述(面板底部轨迹) */
trace: string | null
imported?: BrowserImportInfo | null
/** 当前代理设置(面板展示与编辑;变更即广播) */
proxy: BrowserProxySettings
}
/** 受管浏览器代理设置(作用于 persist:aso-browser 分区;保存后立即生效) */
export interface BrowserProxySettings {
/** system=跟随系统(Electron 默认)| direct=直连 | fixed=手动规则 */
mode: 'system' | 'direct' | 'fixed'
/** fixed 模式:Chromium proxyRules 语法——host:port,多协议用分号分隔,可选 scheme= 前缀(如 http=127.0.0.1:7890;https=127.0.0.1:7890) */
rules?: string
/** 绕过列表(逗号分隔:<local>、*.example.com 等;Chromium bypass 语法) */
bypass?: string
}
export type BrowserUiEvent = { type: 'state'; state: BrowserUiState }
+1 -1
View File
@@ -12,7 +12,7 @@
| v0.4.0 | 工具与审批 | 工具调用卡片(参数/结果折叠展示)、tool_call 拦截 → Approval Card、defineTool 业务工具骨架(本地可跑的演示工具) |
| v0.5.0 | 打磨 | 深浅主题、GSAP 动效细节、快捷键、错误态/空态、队列可视化、自动滚动策略 |
| v0.8.0 | 动态插件系统(M0–M3) | 两层能力模型(内置组件级/外置 iframe)、pi 原生契约聚合 extension、@aso/plugin-sdk、plugin://peer:// 协议、视图切换+聊天保活+徽标、侧栏/面板/胶囊/深链槽位、浏览器面板插件化、插件重载 |
| v0.9.0 | 插件一等公民 + 可观测 | entry.renderer 宿主内 React 组件(Blob 装载+单实例 React)、.pi/extensions 一等插件源(build 开关门控)、声明式设置体系(13 控件+一级导航入口)、统一日志系统(滚动文件/api.log/IPC 追踪/渲染层错误归档)、插件依赖与服务互调(dependencies/optionalDependencies + api.service/requirePlugin + 拓扑波次激活 + 环检测)、插件 SQLite 存储(每插件一库 userData/plugin-data/<id>/data.db + 共享 worker 线程 + 机器可读 declareSchema + 设置页存储区卡片)、渐进式教程 docs/plugins/ + DESIGN.md + plugin-template、性能/样式标准与检验器(激活 10s/装载 8s 护栏、@aso/plugin-sdk/ui 组件库+三态、withTimeout/withRetry、plugin:check)、悬浮状态胶囊(ZCode 复刻:最小化药丸展示最新消息 ⇄ 展开面板每插件一区域、区域可收起、固定高度滚动、行尾彩色数值)、图标统一 lucide 白名单、面板平铺标签条 |
| v0.9.0 | 插件一等公民 + 可观测 | entry.renderer 宿主内 React 组件(Blob 装载+单实例 React)、.pi/extensions 一等插件源(build 开关门控)、声明式设置体系(13 控件+一级导航入口)、统一日志系统(滚动文件/api.log/IPC 追踪/渲染层错误归档)、插件依赖与服务互调(dependencies/optionalDependencies + api.service/requirePlugin + 拓扑波次激活 + 环检测)、插件 SQLite 存储(每插件一库 userData/plugin-data/<id>/data.db + 共享 worker 线程 + 机器可读 declareSchema + 设置页存储区卡片)、渐进式教程 docs/plugins/ + DESIGN.md + plugin-template、性能/样式标准与检验器(激活 10s/装载 8s 护栏、@aso/plugin-sdk/ui 组件库+三态、withTimeout/withRetry、plugin:check)、悬浮状态胶囊(ZCode 复刻:最小化药丸展示最新消息 ⇄ 展开面板每插件一区域、区域可收起、固定高度滚动、行尾彩色数值)、受管浏览器代理设置(persist 分区 session.setProxy:跟随系统/直连/手动 Chromium proxyRules + 绕过列表,保存即生效并对 browser_* 工具同样生效)、图标统一 lucide 白名单、面板平铺标签条 |
| 未来 | | 插件管理与 zip 导入(M4)、ed25519 签名在线更新(M5)、外置插件模板与开发工作流(M6)、pi 原生扩展目录管控(M7)、B4/B5 槽位、electron-builder 打包与自动更新、i18n(中/英)、sqlite 持久化与迁移、本地备份恢复、热点词/生成/模板业务插件、手动模式编排 UI、RPC 子进程隔离模式 |
---