revert(browser): 移除「导入本机浏览器登录态」功能(保留代理设置)

This commit is contained in:
Rick Ye
2026-09-13 02:04:55 +08:00
parent 573ac5bcde
commit 557887c1b3
15 changed files with 49 additions and 1229 deletions
@@ -1,15 +0,0 @@
# ASO Cookie 导出助手
Chrome 127+ 对 Cookie 启用了应用绑定加密(app-bound encryption),浏览器外的程序(包括本应用)无法解密 Cookie 文件。Chrome 官方留给扩展的 `chrome.cookies` API 仍返回明文——这个扩展就是走这条通道,把 Cookie 导出成备份文件,再到 Inkreach Agent 里导入。
## 使用步骤(一次性)
1. 打开 Chrome,地址栏输入 `chrome://extensions/` 回车。
2. 右上角打开「开发者模式」。
3. 点「加载已解压的扩展程序」,选择本目录(`cookie-exporter` 文件夹)。
4. 点浏览器工具栏里的扩展图标(拼图形状)→ 找到「ASO Cookie 导出助手」→ 点「导出全部 Cookie」,选择保存位置。
5. 回到 Inkreach Agent:设置 → 浏览器 → 「Cookie 备份导入」→ 选择刚保存的 JSON 文件,立即生效。
Edge / Brave 等 Chromium 系浏览器同样适用(各自扩展页加载本目录即可)。
> 导出的备份文件包含明文登录凭据,用完建议删除,不要发给别人。
@@ -1,11 +0,0 @@
{
"manifest_version": 3,
"name": "ASO Cookie 导出助手",
"version": "1.0.0",
"description": "把本浏览器的全部 Cookie 导出为 JSON 备份,供 Inkreach Agent 受管浏览器导入(Chrome 应用绑定加密下的官方通道)。",
"permissions": ["cookies", "downloads"],
"host_permissions": ["<all_urls>"],
"action": {
"default_popup": "popup.html"
}
}
@@ -1,58 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ASO Cookie 导出助手</title>
<style>
body {
width: 280px;
margin: 0;
padding: 14px;
font: 13px/1.6 system-ui, 'Segoe UI', 'Microsoft YaHei', sans-serif;
color: #1f2328;
background: #fff;
}
h1 {
margin: 0 0 4px;
font-size: 14px;
}
p {
margin: 0 0 12px;
color: #6b7280;
font-size: 12px;
}
button {
width: 100%;
padding: 8px 0;
font-size: 13px;
color: #fff;
background: #2563eb;
border: 0;
border-radius: 8px;
cursor: pointer;
}
button:hover {
background: #1d4ed8;
}
button:disabled {
opacity: 0.5;
cursor: default;
}
#status {
margin-top: 10px;
min-height: 18px;
font-size: 12px;
color: #374151;
word-break: break-all;
}
</style>
</head>
<body>
<h1>Cookie 导出助手</h1>
<p>导出本浏览器全部 Cookie(明文),到 Inkreach Agent 的 设置 → 浏览器 → Cookie 备份导入 里使用。</p>
<button id="export">导出全部 Cookie</button>
<div id="status"></div>
<script src="popup.js"></script>
</body>
</html>
@@ -1,47 +0,0 @@
// 导出全部 Cookie 为 JSON 备份(chrome.cookies API 返回明文——Chrome 官方提供给扩展的通道)
const statusEl = document.getElementById('status')
const button = document.getElementById('export')
function setStatus(text) {
statusEl.textContent = text
}
button.addEventListener('click', async () => {
button.disabled = true
try {
setStatus('正在读取 Cookie…')
const cookies = await chrome.cookies.getAll({})
if (!cookies || cookies.length === 0) {
setStatus('未读取到任何 Cookie。')
return
}
const payload = {
format: 'aso-cookie-export',
version: 1,
exportedAt: new Date().toISOString(),
cookies: cookies.map((c) => ({
name: c.name,
value: c.value,
domain: c.domain,
hostOnly: c.hostOnly === true,
path: c.path || '/',
secure: c.secure === true,
httpOnly: c.httpOnly === true,
...(typeof c.expirationDate === 'number' ? { expirationDate: c.expirationDate } : {}),
...(c.sameSite && c.sameSite !== 'unspecified' ? { sameSite: c.sameSite } : {})
}))
}
const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const d = new Date()
const pad = (n) => String(n).padStart(2, '0')
const filename = `aso-cookies-${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}.json`
await chrome.downloads.download({ url, filename, saveAs: true })
URL.revokeObjectURL(url)
setStatus(`已导出 ${payload.cookies.length} 条 Cookie。回到 Inkreach Agent:设置 → 浏览器 → Cookie 备份导入,选择刚保存的文件。`)
} catch (err) {
setStatus('导出失败:' + (err && err.message ? err.message : String(err)))
} finally {
button.disabled = false
}
})
@@ -2,9 +2,10 @@ import { getLogger } from '../logger'
const log = getLogger('browser') const log = getLogger('browser')
import { app, BrowserWindow, session as electronSession, WebContentsView, type Rectangle, type Session } from 'electron' import { app, BrowserWindow, session as electronSession, WebContentsView, type Rectangle, type Session } from 'electron'
import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
import { rm as rmAsync } from 'node:fs/promises'
import { join } from 'node:path' import { join } from 'node:path'
import type { BrowserImportInfo, BrowserProxySettings, BrowserTabInfo, BrowserUiState } from '@shared/types' import type { BrowserProxySettings, BrowserTabInfo, BrowserUiState } from '@shared/types'
import { import {
BrowserCdpTimeoutError, BrowserCdpTimeoutError,
BROWSER_OBSERVE_TIMEOUT_MS, BROWSER_OBSERVE_TIMEOUT_MS,
@@ -30,17 +31,13 @@ import {
* 渲染层 BrowserSlot 量出占位矩形,经 IPC 同步边界(原生视图天然盖在 DOM 之上)。 * 渲染层 BrowserSlot 量出占位矩形,经 IPC 同步边界(原生视图天然盖在 DOM 之上)。
* - 页面观察/操作全部走 CDP(webContents.debugger):AX tree 快照 + ref 代际机制 * - 页面观察/操作全部走 CDP(webContents.debugger):AX tree 快照 + ref 代际机制
* + Input.dispatch* 真实输入事件;不在页面里执行模拟点击。 * + Input.dispatch* 真实输入事件;不在页面里执行模拟点击。
* - 登录态隔离在受管分区;本地浏览器配置导入由 browser-profile.ts 完成: * - 登录态隔离在 persist:aso-browser 分区,随分区持久保存。
* 分区轮换(aso-browser-g<n>)+ Cookie 会话 API 热注入,全程无需重启应用。
* - 代理设置作用于同一分区会话(session.setProxy):跟随系统 / 直连 / 手动规则 * - 代理设置作用于同一分区会话(session.setProxy):跟随系统 / 直连 / 手动规则
* (Chromium proxyRules 语法 + 绕过列表),持久化进 aso-browser.json, * (Chromium proxyRules 语法 + 绕过列表),持久化进 aso-browser.json,
* 启动即应用、修改后断开在途连接立即生效(对 Agent 的 browser_* 工具同样生效)。 * 启动即应用、修改后断开在途连接立即生效(对 Agent 的 browser_* 工具同样生效)。
*/ */
// 受管分区名(persist: 前缀之外的磁盘目录名):gen 0 为初始名,导入时轮换 +1 const PARTITION = 'persist:aso-browser'
function partitionNameOf(gen: number): string {
return gen <= 0 ? 'aso-browser' : `aso-browser-g${gen}`
}
// 新标签页默认地址:google.com 在目标市场(国内)不可达,新开标签会长时间白屏, // 新标签页默认地址:google.com 在目标市场(国内)不可达,新开标签会长时间白屏,
// 误以为浏览器故障;用可稳定访问的导航页 // 误以为浏览器故障;用可稳定访问的导航页
const NEW_TAB_URL = 'https://www.baidu.com/' const NEW_TAB_URL = 'https://www.baidu.com/'
@@ -76,25 +73,7 @@ interface TabRecord {
interface BrowserSettings { interface BrowserSettings {
riskAcknowledged: boolean riskAcknowledged: boolean
imported?: BrowserImportInfo | null
proxy?: BrowserProxySettings proxy?: BrowserProxySettings
/** 受管分区代号:导入时轮换 +1;旧分区目录由启动清理回收 */
partitionGen?: number
}
/** 可注入会话的 Cookie 种子(源浏览器解密结果 / 旧分区导出结果;主进程内部用) */
export interface BrowserCookieSeed {
name: string
value: string
/** Chromium 语义:以 . 开头 = 域 Cookie;hostOnly = 仅绑定该主机 */
domain: string
hostOnly?: boolean
path?: string
secure?: boolean
httpOnly?: boolean
/** Unix 秒;缺省 = 会话 Cookie */
expirationDate?: number
sameSite?: 'unspecified' | 'no_restriction' | 'lax' | 'strict'
} }
export interface BrowserObservation { export interface BrowserObservation {
@@ -345,16 +324,12 @@ export class BrowserController {
constructor() { constructor() {
this.settings = this.loadSettings() this.settings = this.loadSettings()
this.browserSession = this.createBrowserSession() this.browserSession = this.createBrowserSession()
this.cleanupLegacyImportArtifacts()
} }
/** 当前受管分区名(persist: 之外的磁盘目录名;导入文件迁移与启动清理按它定位) */ /** 创建受管分区会话并挂上固定策略(UA、权限、下载、代理) */
partitionName(): string {
return partitionNameOf(this.settings.partitionGen ?? 0)
}
/** 创建/重建受管分区会话并挂上固定策略(UA、权限、下载、代理) */
private createBrowserSession(): Session { private createBrowserSession(): Session {
const session = electronSession.fromPartition(`persist:${this.partitionName()}`) const session = electronSession.fromPartition(PARTITION)
// 诚实的 UA:保留 Chromium token 保证站点兼容,去掉 Electron 标识 // 诚实的 UA:保留 Chromium token 保证站点兼容,去掉 Electron 标识
session.setUserAgent(session.getUserAgent().replace(/Electron\/[\d.]+/, `InkreachAgent/${app.getVersion()}`)) session.setUserAgent(session.getUserAgent().replace(/Electron\/[\d.]+/, `InkreachAgent/${app.getVersion()}`))
// 网页权限请求(摄像头/定位等)默认拒绝 // 网页权限请求(摄像头/定位等)默认拒绝
@@ -389,20 +364,15 @@ export class BrowserController {
try { try {
const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as Partial<BrowserSettings> const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as Partial<BrowserSettings>
const proxy = raw.proxy const proxy = raw.proxy
const imported = raw.imported
// 旧版「搁置/需重启」类提示是一次性状态说明,不该常驻展示——历史残留直接清掉
if (imported?.note && /被占用|暂存|搁置|重启/.test(imported.note)) imported.note = undefined
return { return {
riskAcknowledged: raw.riskAcknowledged === true, riskAcknowledged: raw.riskAcknowledged === true,
imported: imported ?? null,
proxy: proxy:
proxy && (proxy.mode === 'direct' || proxy.mode === 'fixed' || proxy.mode === 'system') proxy && (proxy.mode === 'direct' || proxy.mode === 'fixed' || proxy.mode === 'system')
? proxy ? proxy
: { mode: 'system' }, : { mode: 'system' }
partitionGen: typeof raw.partitionGen === 'number' && raw.partitionGen >= 0 ? Math.floor(raw.partitionGen) : 0
} }
} catch { } catch {
return { riskAcknowledged: false, proxy: { mode: 'system' }, partitionGen: 0 } return { riskAcknowledged: false, proxy: { mode: 'system' } }
} }
} }
@@ -414,90 +384,34 @@ export class BrowserController {
} }
} }
/** 配置导入完成后由 browser-profile.ts 回写摘要 */ /** 历史遗留清理:删除已下线的「登录态导入」功能留下的轮换分区目录与暂存
setImportedInfo(info: BrowserImportInfo | null): void { * (aso-browser-g* / .import-staging-* / aso-browser.pending-import)。
this.settings.imported = info * 控制器创建时无人持有这些目录的句柄;失败只记日志,下次启动再试。 */
this.saveSettings() private cleanupLegacyImportArtifacts(): void {
this.emitState() const root = join(app.getPath('userData'), 'Partitions')
} if (!existsSync(root)) return
const stale: string[] = []
get importedInfo(): BrowserImportInfo | null { for (const entry of readdirSync(root, { withFileTypes: true })) {
return this.settings.imported ?? null if (!entry.isDirectory()) continue
} if (
entry.name.startsWith('.import-staging-') ||
/** 分区轮换(导入无感应用的核心):切换到从未被打开过的下一代分区目录。 entry.name === 'aso-browser.pending-import' ||
* 旧分区的 leveldb / Cookies 文件句柄在本进程内无法释放(clearStorageData /^aso-browser-g\d+$/.test(entry.name)
* 只清数据不关句柄),文件热替换必然 EPERM;新目录零句柄零锁,文件直接 ) {
* 写入即生效。旧目录由启动清理回收。 */ stale.push(entry.name)
rotatePartition(): void {
this.settings.partitionGen = (this.settings.partitionGen ?? 0) + 1
this.saveSettings()
this.browserSession = this.createBrowserSession()
}
/** 导入前关闭全部标签并返回 URL(导入完成后恢复);不清存储——数据随分区轮换迁移 */
closeForImport(): string[] {
const urls = [...this.tabs.values()]
.sort((a, b) => a.createdAt - b.createdAt)
.map((t) => t.url)
.filter((u) => /^https?:\/\//.test(u))
this.close()
return urls
}
/** 恢复导入前打开的标签 */
restoreTabs(urls: string[]): void {
for (const url of urls) {
try {
this.newTab(url, false)
} catch (err) {
log.warn('restore tab failed', { url, err: err instanceof Error ? err.message : String(err) })
} }
} }
} if (stale.length === 0) return
void (async () => {
/** 导出当前分区全部 Cookie(会话 API 返回明文;分区轮换前快照,导入后写回新分区) */ for (const name of stale) {
async exportCookies(): Promise<BrowserCookieSeed[]> { try {
const list = await this.browserSession.cookies.get({}) await rmAsync(join(root, name), { recursive: true, force: true, maxRetries: 1 })
return list.map((c) => ({ } catch (err) {
name: c.name, log.warn('清理遗留导入目录失败,下次启动重试', { name, err: err instanceof Error ? err.message : String(err) })
value: c.value, }
domain: c.domain ?? '', }
hostOnly: c.hostOnly === true, log.info('cleaned legacy import artifacts', { stale })
path: c.path || '/', })()
secure: c.secure === true,
httpOnly: c.httpOnly === true,
...(typeof c.expirationDate === 'number' ? { expirationDate: c.expirationDate } : {}),
...(c.sameSite && c.sameSite !== 'unspecified' ? { sameSite: c.sameSite } : {})
}))
}
/** 批量写入当前分区会话(不落文件、天然无锁;分批并发控制在对会话的冲击) */
async importCookies(seeds: BrowserCookieSeed[]): Promise<{ imported: number; failed: number }> {
let imported = 0
let failed = 0
const CHUNK = 64
for (let i = 0; i < seeds.length; i += CHUNK) {
const results = await Promise.allSettled(
seeds.slice(i, i + CHUNK).map((seed) => {
const host = seed.domain.replace(/^\./, '')
return this.browserSession.cookies.set({
url: `${seed.secure ? 'https' : 'http'}://${host}${seed.path || '/'}`,
name: seed.name,
value: seed.value,
...(seed.hostOnly ? {} : { domain: seed.domain }),
path: seed.path || '/',
secure: seed.secure === true,
httpOnly: seed.httpOnly === true,
...(seed.expirationDate ? { expirationDate: seed.expirationDate } : {}),
...(seed.sameSite ? { sameSite: seed.sameSite } : {})
})
})
)
for (const r of results) if (r.status === 'fulfilled') imported++
else failed++
}
return { imported, failed }
} }
// ---- 代理设置 ---- // ---- 代理设置 ----
@@ -573,7 +487,6 @@ export class BrowserController {
activeTabId: this.activeTabId, activeTabId: this.activeTabId,
recentClosed: this.closedTabs.map(({ title, url }) => ({ title, url })), recentClosed: this.closedTabs.map(({ title, url }) => ({ title, url })),
trace: this.traceText, trace: this.traceText,
imported: this.settings.imported ?? null,
proxy: this.proxySettings() proxy: this.proxySettings()
} }
} }
@@ -635,14 +548,13 @@ export class BrowserController {
if (!this.owner || this.owner.isDestroyed()) throw new Error('主窗口尚未就绪,无法创建浏览器标签。') if (!this.owner || this.owner.isDestroyed()) throw new Error('主窗口尚未就绪,无法创建浏览器标签。')
if (this.tabs.size >= MAX_TABS) throw new Error(`浏览器标签页已达上限(${MAX_TABS}),请先关闭不需要的标签。`) if (this.tabs.size >= MAX_TABS) throw new Error(`浏览器标签页已达上限(${MAX_TABS}),请先关闭不需要的标签。`)
const tabId = `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const tabId = `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
// popup 的 viewOptions 不一定带 partition(会导致弹窗落入默认会话),统一强制当前受管分区 // popup 的 viewOptions 不一定带 partition(会导致弹窗落入默认会话),统一强制受管分区
const partition = `persist:${this.partitionName()}`
const view = new WebContentsView( const view = new WebContentsView(
viewOptions viewOptions
? { ...viewOptions, webPreferences: { ...(viewOptions.webPreferences ?? {}), partition } } ? { ...viewOptions, webPreferences: { ...(viewOptions.webPreferences ?? {}), partition: PARTITION } }
: { : {
webPreferences: { webPreferences: {
partition, partition: PARTITION,
nodeIntegration: false, nodeIntegration: false,
contextIsolation: true, contextIsolation: true,
sandbox: true, sandbox: true,
@@ -794,7 +706,7 @@ export class BrowserController {
return this.buildState() return this.buildState()
} }
/** 关闭整个受管浏览器(销毁全部标签;导入配置前也会走这里) */ /** 关闭整个受管浏览器(销毁全部标签;分区会话与登录态保留) */
close(): BrowserUiState { close(): BrowserUiState {
for (const tabId of [...this.tabs.keys()]) this.closeTab(tabId) for (const tabId of [...this.tabs.keys()]) this.closeTab(tabId)
this.agentTabId = null this.agentTabId = null
@@ -1,596 +0,0 @@
import { app } from 'electron'
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs'
import { rm as rmAsync } from 'node:fs/promises'
import { createDecipheriv } from 'node:crypto'
import { execFile } from 'node:child_process'
import { DatabaseSync } from 'node:sqlite'
import { homedir } from 'node:os'
import { join } from 'node:path'
import type { BrowserImportInfo, BrowserImportResult, BrowserProfileMeta, BrowserSource } from '@shared/types'
import { getBrowserController, type BrowserCookieSeed } from './browser-controller'
import { getLogger } from '../logger'
const log = getLogger('browser-profile')
/**
* 本地浏览器配置导入(复用各网站登录态)——全程无感,无需重启应用:
*
* 1) collect:源 profile 的 Local Storage(明文 leveldb)与 Cookies 数据库先拷入
* 暂存目录,源数据全部抓到手;Windows 上用 DPAPI 解出源 Cookie 密钥,
* 自行解密暂存 Cookies 库得到明文 Cookie 列表。
* 2) 分区轮换:受管分区一旦被本应用 Chromium 打开过,其 leveldb / Cookies 文件
* 句柄在本进程内无法释放(clearStorageData 只清数据不关句柄),对存活分区
* 做文件热替换必然 EPERM。因此导入时切换到「从未打开过」的下一代分区目录
* (aso-browser-g<n>)——零句柄零锁,文件直接拷入即生效,搁置/重启不再存在。
* 3) Cookie 走会话 API:受管浏览器自身登录态先从旧会话导出(cookies.get 返回
* 明文)再写回新分区,源 Cookie 解密后逐条注入。全程不碰分区文件。
* 4) 旧分区目录在下次启动早期清理(那时无人持有句柄,见 cleanupStaleBrowserPartitions)。
*/
/** Chromium 系浏览器的用户数据根目录(按平台) */
const SOURCE_ROOTS: Array<{ id: string; name: string; win: string; mac: string; linux: string }> = [
{
id: 'chrome',
name: 'Google Chrome',
win: join(process.env.LOCALAPPDATA ?? '', 'Google', 'Chrome', 'User Data'),
mac: join(homedir(), 'Library', 'Application Support', 'Google', 'Chrome'),
linux: join(homedir(), '.config', 'google-chrome')
},
{
id: 'edge',
name: 'Microsoft Edge',
win: join(process.env.LOCALAPPDATA ?? '', 'Microsoft', 'Edge', 'User Data'),
mac: join(homedir(), 'Library', 'Application Support', 'Microsoft Edge'),
linux: join(homedir(), '.config', 'microsoft-edge')
},
{
id: 'brave',
name: 'Brave',
win: join(process.env.LOCALAPPDATA ?? '', 'BraveSoftware', 'Brave-Browser', 'User Data'),
mac: join(homedir(), 'Library', 'Application Support', 'BraveSoftware', 'Brave-Browser'),
linux: join(homedir(), '.config', 'BraveSoftware', 'Brave-Browser')
},
{
id: 'chromium',
name: 'Chromium',
win: join(process.env.LOCALAPPDATA ?? '', 'Chromium', 'User Data'),
mac: join(homedir(), 'Library', 'Application Support', 'Chromium'),
linux: join(homedir(), '.config', 'chromium')
}
]
/** 所有受管分区目录的根(userData/Partitions) */
function partitionsRoot(): string {
return join(app.getPath('userData'), 'Partitions')
}
/** 指定分区名的磁盘目录 */
function partitionDirOf(name: string): string {
return join(partitionsRoot(), name)
}
function sourceRoot(id: string): string {
const source = SOURCE_ROOTS.find((s) => s.id === id)
if (!source) return ''
return process.platform === 'win32' ? source.win : process.platform === 'darwin' ? source.mac : source.linux
}
/** 读取 profile 的展示名(Preferences 的 profile.name,失败时用目录名) */
function readProfileName(profileDir: string): string {
try {
const file = join(profileDir, 'Preferences')
if (statSync(file).size > 8 * 1024 * 1024) return profileDir.split(/[\\/]/).pop() || profileDir
const raw = JSON.parse(readFileSync(file, 'utf8')) as { profile?: { name?: string } }
return raw.profile?.name?.trim() || profileDir.split(/[\\/]/).pop() || profileDir
} catch {
return profileDir.split(/[\\/]/).pop() || profileDir
}
}
/** 检测本机可导入的浏览器与 profile 列表 */
export function detectBrowserSources(): BrowserSource[] {
const sources: BrowserSource[] = []
for (const source of SOURCE_ROOTS) {
const root = sourceRoot(source.id)
if (!root || !existsSync(root)) continue
const profiles: BrowserProfileMeta[] = []
try {
for (const entry of readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue
if (entry.name.startsWith('.') || entry.name === 'System Profile' || entry.name === 'Guest Profile') continue
const profileDir = join(root, entry.name)
if (!existsSync(join(profileDir, 'Preferences'))) continue
profiles.push({ dir: entry.name, name: readProfileName(profileDir) })
}
} catch {
// 目录不可读则跳过该浏览器
continue
}
if (profiles.length > 0) {
sources.push({ id: source.id, name: source.name, userDataDir: root, profiles })
}
}
return sources
}
function isLockError(err: unknown): boolean {
const code = (err as NodeJS.ErrnoException).code
return code === 'EBUSY' || code === 'EPERM' || code === 'EACCES'
}
function copyFile(from: string, to: string): void {
copyFileSync(from, to)
}
/** 拷贝 leveldb 目录(跳过 LOCK/LOG 运行时文件),目标已存在时先清空 */
function copyLevelDbDir(from: string, to: string): boolean {
if (!existsSync(from)) return false
const entries = readdirSync(from, { withFileTypes: true })
const files = entries.filter((e) => e.isFile() && e.name !== 'LOCK' && !e.name.startsWith('LOG'))
if (files.length === 0) return false
rmSync(to, { recursive: true, force: true })
mkdirSync(to, { recursive: true })
for (const file of files) {
copyFile(join(from, file.name), join(to, file.name))
}
return true
}
interface SourceKeyInfo {
key: string | null
/** 不可解密场景(非 Windows / app-bound V20 / 缺失)的说明 */
note?: string
}
/**
* 读取源浏览器 Local State 里的 os_crypt.encrypted_key 并判断可用性。
* Windows:DPAPI 前缀可解(PowerShell CryptUnprotectData,用户级);
* V20 为 app-bound 加密(Chrome/Edge 127+),第三方无法解密,明确降级。
* macOS/Linux:密钥在系统钥匙串/KWallet,仅迁移 Local Storage。
*/
function readSourceKey(userDataDir: string): SourceKeyInfo {
if (process.platform !== 'win32') {
return { key: null, note: '当前平台的浏览器 Cookie 密钥绑定系统钥匙串,暂不支持迁移 Cookie(LocalStorage 登录态仍会导入)。' }
}
try {
const raw = JSON.parse(readFileSync(join(userDataDir, 'Local State'), 'utf8')) as {
os_crypt?: { encrypted_key?: string }
}
const encoded = raw.os_crypt?.encrypted_key
if (!encoded) return { key: null, note: '未找到源浏览器 Cookie 密钥,跳过 Cookie 迁移。' }
const bytes = Buffer.from(encoded, 'base64')
if (bytes.subarray(0, 3).toString('utf8') === 'V20') {
return { key: null, note: '该浏览器启用了应用绑定加密(app-bound),Cookie 无法第三方迁移;LocalStorage 已导入,需登录的站点在受管浏览器里登录一次即可。' }
}
if (bytes.subarray(0, 5).toString('utf8') !== 'DPAPI') {
return { key: null, note: '源浏览器 Cookie 密钥格式不受支持,跳过 Cookie 迁移。' }
}
return { key: encoded }
} catch {
return { key: null, note: '读取源浏览器 Local State 失败,跳过 Cookie 迁移。' }
}
}
/** DPAPI 解出源浏览器的 AES 密钥(PowerShell 调 CryptUnprotectData;用户级,无需管理员)。
* Chromium 的 encrypted_key = base64("DPAPI" + 密文 blob):CryptUnprotectData 只接受
* 剥掉 5 字节前缀后的 blob,前缀判断必须在解码后的字节上做——base64 文本本身
* 永远不以 "DPAPI" 开头(实测带前缀调用必然 CryptographicException: 数据无效)。 */
function dpapiUnprotectKey(encodedKey: string): Promise<Buffer | null> {
const bytes = Buffer.from(encodedKey, 'base64')
const blob = bytes.subarray(0, 5).toString('utf8') === 'DPAPI' ? bytes.subarray(5).toString('base64') : encodedKey
const script =
`$d=[Convert]::FromBase64String('${blob}');` +
'Add-Type -AssemblyName System.Security;' +
'[Convert]::ToBase64String([System.Security.Cryptography.ProtectedData]::Unprotect($d,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser))'
return new Promise((resolve) => {
execFile(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-Command', script],
{ timeout: 20_000 },
(err, stdout, stderr) => {
if (err || !stdout.trim()) {
log.warn('dpapi unprotect failed', {
err: err instanceof Error ? err.message : String(err ?? 'empty output'),
stderr: stderr ? String(stderr).slice(0, 200) : undefined
})
resolve(null)
return
}
try {
resolve(Buffer.from(stdout.trim(), 'base64'))
} catch {
resolve(null)
}
}
)
})
}
/** 解密单条 Chrome Cookie(v10/v11 均为 AES-256-GCM:3 字节版本 + 12 字节 nonce + 密文 + 16 字节 tag)。
* 注意 node:sqlite 返回的 BLOB 是裸 Uint8Array 而非 Buffer——其 toString() 会忽略
* 编码参数、返回逗号拼接的数字,必须先转成 Buffer 才能按 utf8 读版本头。 */
function decryptChromeBlob(encrypted: Uint8Array, key: Buffer): string {
const blob = Buffer.isBuffer(encrypted) ? encrypted : Buffer.from(encrypted)
const version = blob.subarray(0, 3).toString('utf8')
if (version !== 'v10' && version !== 'v11') throw new Error(`不支持的 Cookie 加密版本: ${version}`)
const nonce = blob.subarray(3, 15)
const tag = blob.subarray(blob.length - 16)
const data = blob.subarray(15, blob.length - 16)
const decipher = createDecipheriv('aes-256-gcm', key, nonce)
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8')
}
/** 源 Cookies 库 samesite 列值 → Electron 语义(-1 未指定 / 0 不限制 / 1 lax / 2 strict) */
function mapSameSite(value: number | null | undefined): BrowserCookieSeed['sameSite'] {
if (value === 0) return 'no_restriction'
if (value === 1) return 'lax'
if (value === 2) return 'strict'
return undefined
}
interface CollectedImport {
/** 暂存目录里已有 Cookies 数据库文件(可作解密源) */
cookies: boolean
localStorage: boolean
cookieNote?: string
}
/**
* collect:源 profile → 暂存目录(Local Storage/leveldb + Network/Cookies)。
* 源文件被占用时抛出可操作的提示;Cookie 拷贝失败不阻塞(降级只导 Local Storage)。
*/
function collectFromSource(root: string, sourceProfile: string, staging: string): CollectedImport {
let localStorage = false
try {
localStorage = copyLevelDbDir(join(sourceProfile, 'Local Storage', 'leveldb'), join(staging, 'Local Storage', 'leveldb'))
} catch (err) {
if (isLockError(err)) {
throw new Error('源浏览器文件被占用:请完全退出对应的浏览器(含后台进程)后重试。')
}
throw new Error(`Local Storage 迁移失败: ${err instanceof Error ? err.message : String(err)}`)
}
let cookies = false
let cookieNote: string | undefined
const keyInfo = readSourceKey(root)
const cookieDb = existsSync(join(sourceProfile, 'Network', 'Cookies'))
? join(sourceProfile, 'Network', 'Cookies')
: existsSync(join(sourceProfile, 'Cookies'))
? join(sourceProfile, 'Cookies')
: null
if (keyInfo.key && cookieDb) {
try {
mkdirSync(join(staging, 'Network'), { recursive: true })
copyFile(cookieDb, join(staging, 'Network', 'Cookies'))
for (const suffix of ['-wal', '-shm']) {
if (existsSync(`${cookieDb}${suffix}`)) copyFile(`${cookieDb}${suffix}`, join(staging, 'Network', `Cookies${suffix}`))
}
cookies = true
} catch (err) {
cookieNote = isLockError(err)
? '源浏览器 Cookie 文件被占用:请完全退出该浏览器后重试。'
: err instanceof Error
? err.message
: 'Cookie 迁移失败。'
}
} else {
cookieNote = keyInfo.note ?? '源浏览器没有可迁移的 Cookie 数据库。'
}
return { cookies, localStorage, cookieNote }
}
/** apply(LocalStorage):暂存 → 指定分区目录(目标应是未被 Chromium 打开过的目录) */
function applyStagedLocalStorage(staging: string, targetPartitionDir: string): void {
copyLevelDbDir(join(staging, 'Local Storage', 'leveldb'), join(targetPartitionDir, 'Local Storage', 'leveldb'))
}
type SourceCookieRead =
| { kind: 'ok'; cookies: BrowserCookieSeed[]; v20: number; undecryptable: number }
| { kind: 'unsupported'; note: string }
/**
* 解密暂存的源 Cookies 数据库 → 明文 Cookie 种子列表(之后经会话 API 注入,
* 不碰分区文件,天然无锁)。返回 unsupported 表示当前环境无法解密。
*/
async function readSourceCookies(root: string, staging: string): Promise<SourceCookieRead> {
if (process.platform !== 'win32') {
return { kind: 'unsupported', note: '当前平台的浏览器 Cookie 密钥绑定系统钥匙串,暂不支持迁移 Cookie(LocalStorage 登录态仍会导入)。' }
}
const keyInfo = readSourceKey(root)
if (!keyInfo.key) return { kind: 'unsupported', note: keyInfo.note ?? '无可用 Cookie 密钥,跳过 Cookie 迁移。' }
const dbFile = join(staging, 'Network', 'Cookies')
if (!existsSync(dbFile)) return { kind: 'unsupported', note: '源浏览器没有可迁移的 Cookie 数据库。' }
const key = await dpapiUnprotectKey(keyInfo.key)
if (!key) {
return { kind: 'unsupported', note: 'Cookie 密钥解密失败(DPAPI),本次未导入 Cookie,可稍后重试。' }
}
const db = new DatabaseSync(dbFile, { readOnly: true })
try {
// expires_utc 是 1601 纪元的微秒 int64(超过 Number.MAX_SAFE_INTEGER,
// node:sqlite 裸读直接抛 ERR_OUT_OF_RANGE)——纪元换算放在 SQL 里做:
// int64 除法精确,结果秒值 ~4e8 在安全整数范围内
const rows = db
.prepare(
'SELECT host_key, name, value, encrypted_value, path, is_secure, is_httponly, ' +
'CASE WHEN expires_utc > 0 THEN expires_utc / 1000000 - 11644473600 ELSE 0 END AS expires_sec, ' +
'samesite FROM cookies'
)
.all()
const cookies: BrowserCookieSeed[] = []
let v20 = 0
let undecryptable = 0
for (const row of rows) {
try {
const r = row as {
host_key: string
name: string
value: string
/** node:sqlite 的 BLOB 是裸 Uint8Array(非 Buffer),用前必须转换 */
encrypted_value: Uint8Array | null
path: string
is_secure: number
is_httponly: number
expires_sec: number
samesite: number | null
}
let value = r.value
if (!value && r.encrypted_value && r.encrypted_value.length > 3) {
const encrypted = Buffer.from(r.encrypted_value)
// v20 = 应用绑定加密(Chrome 127+ 逐步启用),第三方无法解密,单独计数便于如实提示
if (encrypted.subarray(0, 3).toString('utf8') === 'v20') {
v20++
continue
}
value = decryptChromeBlob(encrypted, key)
}
if (!value) {
undecryptable++
continue
}
cookies.push({
name: r.name,
value,
domain: r.host_key,
hostOnly: !r.host_key.startsWith('.'),
path: r.path || '/',
secure: r.is_secure === 1,
httpOnly: r.is_httponly === 1,
...(r.expires_sec > 0 ? { expirationDate: r.expires_sec } : {}),
...(mapSameSite(r.samesite) ? { sameSite: mapSameSite(r.samesite) } : {})
})
} catch {
undecryptable++
}
}
return { kind: 'ok', cookies, v20, undecryptable }
} finally {
db.close()
}
}
/** 导入互斥:同一时刻只允许一个导入在跑(分区轮换不是并发安全的) */
let importInFlight = false
/** 导入指定浏览器的 profile 配置到受管浏览器分区(无感热应用:无需重启应用,无搁置路径) */
export async function importBrowserProfile(sourceId: string, profileDir: string): Promise<BrowserImportResult> {
if (importInFlight) throw new Error('已有导入正在进行,请稍候。')
importInFlight = true
try {
return await importBrowserProfileInner(sourceId, profileDir)
} finally {
importInFlight = false
}
}
async function importBrowserProfileInner(sourceId: string, profileDir: string): Promise<BrowserImportResult> {
const browser = getBrowserController()
const root = sourceRoot(sourceId)
if (!root || !existsSync(root)) throw new Error('未找到该浏览器的用户数据目录。')
// 只接受该根目录下的直接子目录名,拒绝任意路径注入
const dirName = profileDir.split(/[\\/]/).pop() ?? ''
const sourceProfile = join(root, dirName)
if (dirName !== profileDir || !existsSync(join(sourceProfile, 'Preferences'))) {
throw new Error('profile 目录不存在。')
}
const sourceName = SOURCE_ROOTS.find((s) => s.id === sourceId)?.name ?? sourceId
const profileName = readProfileName(sourceProfile)
// 1) collect + 源 Cookie 解密(源数据全部先抓到手,之后源浏览器锁不锁都无所谓)
const staging = join(partitionsRoot(), `.import-staging-${Date.now()}`)
let collected: CollectedImport
try {
mkdirSync(staging, { recursive: true })
collected = collectFromSource(root, sourceProfile, staging)
} catch (err) {
rmSync(staging, { recursive: true, force: true })
throw err
}
const sourceRead: SourceCookieRead = collected.cookieNote
? { kind: 'unsupported', note: collected.cookieNote }
: await readSourceCookies(root, staging)
// 2) 受管浏览器自身的登录态快照(随分区轮换带过去;会话 API 返回明文)
const carried = await browser.exportCookies()
// 3) 关闭标签(导入完成后按 URL 恢复)
const urls = browser.closeForImport()
// 4) 分区轮换 + Local Storage 文件迁移:当前分区已有 Local Storage 落盘数据
// 说明其句柄可能被本应用 Chromium 持有,直接轮换到全新目录(零句柄零锁)
if (existsSync(join(partitionDirOf(browser.partitionName()), 'Local Storage'))) {
browser.rotatePartition()
}
try {
applyStagedLocalStorage(staging, partitionDirOf(browser.partitionName()))
} catch (err) {
browser.restoreTabs(urls)
rmSync(staging, { recursive: true, force: true })
throw new Error(`导入写入失败: ${err instanceof Error ? err.message : String(err)}`)
}
// 5) Cookie:先回写受管浏览器原有登录态,再注入源 Cookie(全程会话 API,不碰文件)
const carry = await browser.importCookies(carried)
let injected = 0
let cookieNote: string | undefined
if (sourceRead.kind === 'ok') {
const result = await browser.importCookies(sourceRead.cookies)
injected = result.imported
if (sourceRead.cookies.length === 0 && sourceRead.v20 > 0) {
// 全量 v20:文件路径对 Chrome 127+ 已封死,明确引导走扩展备份通道
cookieNote = `Chrome 已启用应用绑定加密(v20),${sourceRead.v20} 条 Cookie 在浏览器外无法解密;请用上方「Cookie 备份导入」补齐(配套扩展一键导出)。LocalStorage 已生效。`
} else {
const skipNotes: string[] = []
if (sourceRead.v20 > 0) skipNotes.push(`${sourceRead.v20} 条为应用绑定加密(v20)无法迁移,可用「Cookie 备份导入」补齐`)
const failedCount = result.failed + sourceRead.undecryptable
if (failedCount > 0) skipNotes.push(`${failedCount} 条解密失败已跳过`)
if (skipNotes.length > 0) cookieNote = `源 Cookie ${injected} 条已注入,${skipNotes.join(',')}。`
}
} else {
cookieNote = sourceRead.note
}
if (!cookieNote && carry.failed > 0) {
cookieNote = `受管浏览器原有 Cookie ${carry.imported} 条已保留,${carry.failed} 条写回失败。`
}
// 6) 收尾:恢复标签、清理暂存、记录导入来源(供「已导入」展示与一键同步)
browser.restoreTabs(urls)
rmSync(staging, { recursive: true, force: true })
const info: BrowserImportInfo = {
sourceId,
browser: sourceName,
profile: profileName,
profileDir: dirName,
at: new Date().toISOString(),
cookies: injected > 0,
localStorage: collected.localStorage,
...(injected > 0 ? { cookieCount: injected } : {}),
...(cookieNote ? { note: cookieNote } : {})
}
browser.setImportedInfo(info)
log.info('profile imported', { source: sourceId, profile: dirName, cookies: injected, localStorage: collected.localStorage })
const parts: string[] = []
if (injected > 0) parts.push(`Cookie ${injected} 条`)
if (collected.localStorage) parts.push('LocalStorage')
const summary = parts.length > 0 ? parts.join(' + ') : '未发现可迁移数据'
return {
ok: true,
cookies: injected > 0,
localStorage: collected.localStorage,
...(cookieNote ? { cookieNote } : {}),
message: `已从 ${sourceName}「${profileName}」导入:${summary}。${urls.length > 0 ? '原标签页已自动恢复。' : ''}${cookieNote ?? ''}`
}
}
/**
* 导入配套扩展导出的 Cookie 备份文件(chrome.cookies API 明文通道——Chrome 127+
* 应用绑定加密后浏览器外无法解密 Cookie,官方留给扩展的 API 仍返回明文)。
* 纯会话 API 注入:无需重启、无需关闭标签,立即生效。
*/
const MAX_BACKUP_BYTES = 32 * 1024 * 1024
const MAX_BACKUP_COOKIES = 50_000
export async function importCookiesFromFile(filePath: string): Promise<BrowserImportResult> {
const browser = getBrowserController()
if (!/\.json$/i.test(filePath)) throw new Error('请选择扩展导出的 .json 备份文件。')
let raw: string
try {
if (statSync(filePath).size > MAX_BACKUP_BYTES) throw new Error('文件过大(超过 32MB),不是有效的备份文件。')
raw = readFileSync(filePath, 'utf8')
} catch (err) {
if (err instanceof Error && err.message.startsWith('文件过大')) throw err
throw new Error('读取备份文件失败,请重新选择。')
}
let parsed: { format?: unknown; version?: unknown; cookies?: unknown }
try {
parsed = JSON.parse(raw) as typeof parsed
} catch {
throw new Error('备份文件不是有效的 JSON,请用「ASO Cookie 导出助手」扩展重新导出。')
}
if (parsed.format !== 'aso-cookie-export' || !Array.isArray(parsed.cookies)) {
throw new Error('备份文件格式不识别,请用「ASO Cookie 导出助手」扩展导出。')
}
if (parsed.cookies.length === 0) throw new Error('备份文件里没有 Cookie。')
if (parsed.cookies.length > MAX_BACKUP_COOKIES) throw new Error('Cookie 数量异常(超过 5 万条),已拒绝导入。')
const seeds: BrowserCookieSeed[] = []
let invalid = 0
for (const item of parsed.cookies) {
try {
const c = item as Record<string, unknown>
if (typeof c.name !== 'string' || typeof c.value !== 'string' || typeof c.domain !== 'string' || !c.name || !c.domain) {
invalid++
continue
}
seeds.push({
name: c.name,
value: c.value,
domain: c.domain,
hostOnly: c.hostOnly === true,
path: typeof c.path === 'string' && c.path ? c.path : '/',
secure: c.secure === true,
httpOnly: c.httpOnly === true,
...(typeof c.expirationDate === 'number' && c.expirationDate > 0 ? { expirationDate: c.expirationDate } : {}),
...(c.sameSite === 'no_restriction' || c.sameSite === 'lax' || c.sameSite === 'strict' ? { sameSite: c.sameSite } : {})
})
} catch {
invalid++
}
}
if (seeds.length === 0) throw new Error('备份文件里没有可用的 Cookie 条目。')
const result = await browser.importCookies(seeds)
const previous = browser.importedInfo
browser.setImportedInfo({
sourceId: 'file',
browser: 'Cookie 备份文件',
profile: '',
profileDir: '',
at: new Date().toISOString(),
cookies: result.imported > 0,
localStorage: previous?.localStorage ?? false,
...(result.imported > 0 ? { cookieCount: result.imported } : {}),
note: result.failed > 0 || invalid > 0 ? `${result.failed + invalid} 条无效已跳过。` : undefined
})
log.info('cookie backup imported', { injected: result.imported, failed: result.failed, invalid })
return {
ok: result.imported > 0,
cookies: result.imported > 0,
localStorage: previous?.localStorage ?? false,
...(result.failed > 0 || invalid > 0 ? { cookieNote: `${result.failed + invalid} 条无效已跳过。` } : {}),
message:
result.imported > 0
? `已从备份文件注入 Cookie ${result.imported} 条,立即生效。${result.failed + invalid > 0 ? `${result.failed + invalid} 条无效已跳过。` : ''}`
: '备份文件里的 Cookie 全部写入失败,请重试。'
}
}
/**
* 启动早期调用(分区尚未被任何页面打开):清理上一代分区目录与残留暂存。
* 失败只记日志(旧目录多留一启动无害,下次再试)。
*/
export async function cleanupStaleBrowserPartitions(): Promise<void> {
const root = partitionsRoot()
if (!existsSync(root)) return
const keep = getBrowserController().partitionName()
const stale: string[] = []
for (const entry of readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue
if (entry.name === keep) continue
if (entry.name.startsWith('.import-staging-') || entry.name === 'aso-browser.pending-import' || /^aso-browser(-g\d+)?$/.test(entry.name)) {
stale.push(entry.name)
}
}
if (stale.length === 0) return
for (const name of stale) {
try {
await rmAsync(join(root, name), { recursive: true, force: true, maxRetries: 1 })
} catch (err) {
log.warn('清理旧分区目录失败,下次启动重试', { name, err: err instanceof Error ? err.message : String(err) })
}
}
log.info('cleaned stale browser partitions', { stale })
}
-28
View File
@@ -1,16 +1,9 @@
import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron' import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron'
import { mkdirSync, readFileSync } from 'node:fs' import { mkdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { userInfo } from 'node:os' import { userInfo } from 'node:os'
import { AgentService } from './pi/agent-service' import { AgentService } from './pi/agent-service'
import { getPluginHost } from './plugins/plugin-host' import { getPluginHost } from './plugins/plugin-host'
import { getBrowserController } from './browser/browser-controller' import { getBrowserController } from './browser/browser-controller'
import {
cleanupStaleBrowserPartitions,
detectBrowserSources,
importBrowserProfile,
importCookiesFromFile
} from './browser/browser-profile'
import { getLogger } from './logger' import { getLogger } from './logger'
const log = getLogger('ipc') const log = getLogger('ipc')
@@ -408,25 +401,6 @@ export function registerIpc(): AgentService {
return browserPass(() => getBrowserController().applyLayout({ tabId, visible, revision, bounds: { x, y, width, height } })) return browserPass(() => getBrowserController().applyLayout({ tabId, visible, revision, bounds: { x, y, width, height } }))
}) })
// ---- 本地浏览器配置导入 ----
handle('browser:detectSources', () => browserPass(() => detectBrowserSources()))
handle('browser:importProfile', (_e, sourceId: unknown, profileDir: unknown) => {
if (typeof sourceId !== 'string' || typeof profileDir !== 'string') throw new Error('invalid import source')
return browserPass(() => importBrowserProfile(sourceId, profileDir))
})
// Cookie 备份文件导入(Chrome 应用绑定加密下的扩展通道)+ 配套扩展目录
handle('browser:importCookiesFile', (_e, path: unknown) => {
if (typeof path !== 'string' || !path) throw new Error('invalid cookie backup path')
return browserPass(() => importCookiesFromFile(path))
})
handle('browser:openExporterFolder', () => {
const folder = app.isPackaged
? join(process.resourcesPath, 'cookie-exporter')
: join(app.getAppPath(), 'resources', 'cookie-exporter')
return shell.openPath(folder)
})
// ---- 受管浏览器代理设置(persist:aso-browser 分区,保存即生效) ---- // ---- 受管浏览器代理设置(persist:aso-browser 分区,保存即生效) ----
handle('browser:getProxy', () => getBrowserController().getProxySettings()) handle('browser:getProxy', () => getBrowserController().getProxySettings())
handle('browser:setProxy', (_e, input: unknown) => { handle('browser:setProxy', (_e, input: unknown) => {
@@ -440,8 +414,6 @@ export function registerIpc(): AgentService {
// ---- 插件系统 ---- // ---- 插件系统 ----
void app.whenReady().then(() => { void app.whenReady().then(() => {
// 清理上一代受管分区与残留暂存(此刻无人持有句柄;导入已无搁置路径)
void cleanupStaleBrowserPartitions()
// 插件启停/重载会重建 agent 运行时(extensionFactories 重跑) // 插件启停/重载会重建 agent 运行时(extensionFactories 重跑)
getPluginHost().setRuntimeRebuilder(() => service.rebuildRuntime()) getPluginHost().setRuntimeRebuilder(() => service.rebuildRuntime())
}) })
@@ -18,7 +18,7 @@ export const browserPlugin: BuiltinPlugin = {
description: '受管浏览器', description: '受管浏览器',
icon: 'Globe' icon: 'Globe'
}, },
/** 宿主专属二级设置页(代理设置 + 登录态导入;见 SettingsModal 插件页特判) */ /** 宿主专属二级设置页(代理设置;见 SettingsModal 插件页特判) */
settingsPage: true, settingsPage: true,
main: (api) => { main: (api) => {
for (const tool of browserTools) { for (const tool of browserTools) {
+1 -10
View File
@@ -1,11 +1,9 @@
import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron' import { contextBridge, ipcRenderer, webFrame } from 'electron'
import type { import type {
AgentUiEvent, AgentUiEvent,
AppSettings, AppSettings,
AttachmentInfo, AttachmentInfo,
BrowserImportResult,
BrowserProxySettings, BrowserProxySettings,
BrowserSource,
BrowserUiEvent, BrowserUiEvent,
BrowserUiState, BrowserUiState,
ChatResources, ChatResources,
@@ -127,13 +125,6 @@ const api = {
revision: number revision: number
bounds: { x: number; y: number; width: number; height: number } bounds: { x: number; y: number; width: number; height: number }
}): Promise<void> => ipcRenderer.invoke('browser:layout', layout), }): Promise<void> => ipcRenderer.invoke('browser:layout', layout),
browserDetectSources: (): Promise<BrowserSource[]> => ipcRenderer.invoke('browser:detectSources'),
browserImportProfile: (sourceId: string, profileDir: string): Promise<BrowserImportResult> =>
ipcRenderer.invoke('browser:importProfile', sourceId, profileDir),
browserImportCookiesFile: (path: string): Promise<BrowserImportResult> =>
ipcRenderer.invoke('browser:importCookiesFile', path),
browserOpenExporterFolder: (): Promise<string> => ipcRenderer.invoke('browser:openExporterFolder'),
pathForFile: (file: File): string => webUtils.getPathForFile(file),
browserGetProxy: (): Promise<BrowserProxySettings> => ipcRenderer.invoke('browser:getProxy'), browserGetProxy: (): Promise<BrowserProxySettings> => ipcRenderer.invoke('browser:getProxy'),
browserSetProxy: (input: BrowserProxySettings): Promise<BrowserProxySettings> => browserSetProxy: (input: BrowserProxySettings): Promise<BrowserProxySettings> =>
ipcRenderer.invoke('browser:setProxy', input), ipcRenderer.invoke('browser:setProxy', input),
@@ -38,7 +38,6 @@ import { THEME_MODES, THEME_PRESETS, applyThemeColor } from '../lib/theme'
import { usePluginList } from '../plugins/store' import { usePluginList } from '../plugins/store'
import { PluginIconValue } from '../plugins/plugin-icons' import { PluginIconValue } from '../plugins/plugin-icons'
import { BrowserProxyForm } from './browser/BrowserProxyForm' import { BrowserProxyForm } from './browser/BrowserProxyForm'
import { BrowserImportForm } from './browser/BrowserImportForm'
type PluginSettingsTab = `plugin:${string}` type PluginSettingsTab = `plugin:${string}`
type Tab = 'general' | 'model' | 'prompt' | 'plugins' | 'skills' | 'commands' | PluginSettingsTab type Tab = 'general' | 'model' | 'prompt' | 'plugins' | 'skills' | 'commands' | PluginSettingsTab
@@ -1674,7 +1673,7 @@ export function SettingsModal({
{tab.startsWith('plugin:') && {tab.startsWith('plugin:') &&
(() => { (() => {
const pid = tab.slice(7) const pid = tab.slice(7)
// 内置浏览器:专属二级页(代理设置 + 登录态导入),不走向声明式设置表单 // 内置浏览器:专属二级页(代理设置),不走向声明式设置表单
if (pid === 'browser') { if (pid === 'browser') {
return ( return (
<div className="flex h-full min-h-0 flex-col gap-4 overflow-y-auto pb-2 pr-1"> <div className="flex h-full min-h-0 flex-col gap-4 overflow-y-auto pb-2 pr-1">
@@ -1684,12 +1683,6 @@ export function SettingsModal({
<BrowserProxyForm /> <BrowserProxyForm />
</div> </div>
</section> </section>
<section className="shrink-0 rounded-card border border-line">
<div className="border-b border-line px-3 py-2 text-[12px] font-medium text-ink">导入本机浏览器登录态</div>
<div className="p-3">
<BrowserImportForm />
</div>
</section>
</div> </div>
) )
} }
@@ -1,232 +0,0 @@
/**
* 本机浏览器登录态导入表单(面板浮层与设置页二级页共用)。
* 选择本机浏览器(Chrome/Edge/Brave/Chromium)的一个 Profile,
* 把各站点登录态(Cookie / Local Storage)迁移到受管浏览器分区。
* 导入全程无感:无需重启应用;已导入的来源常驻展示,可一键「同步」复导。
* 自含数据源:直接调用 window.api(browserDetectSources / browserImportProfile)。
*/
import { useEffect, useState, type JSX } from 'react'
import { LuChevronDown, LuRefreshCw } from 'react-icons/lu'
import type { BrowserImportResult, BrowserSource, BrowserUiState } from '@shared/types'
function formatAt(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
const pad = (n: number): string => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
export function BrowserImportForm(): JSX.Element {
const [state, setState] = useState<BrowserUiState | null>(null)
const [sources, setSources] = useState<BrowserSource[] | null>(null)
const [selected, setSelected] = useState<{ sourceId: string; profileDir: string } | null>(null)
const [busy, setBusy] = useState(false)
const [result, setResult] = useState<BrowserImportResult | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let alive = true
window.api
.browserGetState()
.then((s) => {
if (alive) setState(s)
})
.catch(() => undefined)
const off = window.api.onBrowserEvent((event) => {
if (event.type === 'state' && alive) setState(event.state)
})
return () => {
alive = false
off()
}
}, [])
useEffect(() => {
let alive = true
window.api
.browserDetectSources()
.then((list) => {
if (!alive) return
setSources(list)
const first = list[0]
if (first?.profiles[0]) setSelected({ sourceId: first.id, profileDir: first.profiles[0].dir })
})
.catch((err) => {
if (alive) setError(err instanceof Error ? err.message : String(err))
})
return () => {
alive = false
}
}, [])
const runImport = (target: { sourceId: string; profileDir: string } | null): void => {
if (!target || busy) return
setBusy(true)
setError(null)
setResult(null)
window.api
.browserImportProfile(target.sourceId, target.profileDir)
.then(setResult)
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
.finally(() => setBusy(false))
}
const runBackupImport = (file: File | null): void => {
if (!file || busy) return
setBusy(true)
setError(null)
setResult(null)
try {
const path = window.api.pathForFile(file)
window.api
.browserImportCookiesFile(path)
.then(setResult)
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
.finally(() => setBusy(false))
} catch (err) {
setBusy(false)
setError(err instanceof Error ? err.message : String(err))
}
}
const imported = state?.imported ?? null
return (
<div className="text-[12px] text-ink-2">
<p className="mb-3 leading-relaxed text-ink-3">
迁移 Cookie / Local Storage 登录态,导入后立即生效。导入前请
<span className="font-medium text-ink-2">完全退出</span>源浏览器(含后台进程)。
</p>
{/* Chrome 新版应用绑定加密:文件导入拿不到 Cookie,走配套扩展的官方明文通道 */}
<div className="mb-3 rounded border border-line px-2.5 py-2">
<div className="flex items-center justify-between gap-2">
<span className="text-[12px] font-medium text-ink-2">Cookie 备份导入</span>
<div className="flex shrink-0 items-center gap-1.5">
<button
type="button"
disabled={busy}
onClick={() => void window.api.browserOpenExporterFolder()}
className="rounded border border-line-strong px-2 py-0.5 text-[11px] text-ink-2 transition-colors hover:bg-hover hover:text-ink disabled:opacity-40"
>
扩展目录
</button>
<label
className={`cursor-pointer rounded border border-line-strong px-2 py-0.5 text-[11px] text-ink-2 transition-colors hover:bg-hover hover:text-ink ${
busy ? 'pointer-events-none opacity-40' : ''
}`}
>
选择备份文件
<input
type="file"
accept=".json,application/json"
className="hidden"
disabled={busy}
onChange={(e) => {
runBackupImport(e.target.files?.[0] ?? null)
e.currentTarget.value = ''
}}
/>
</label>
</div>
</div>
<div className="mt-1 text-[10.5px] leading-relaxed text-ink-3">
Chrome 新版已加密 Cookie(文件导入拿不到)。用配套扩展导出备份:点「扩展目录」,在 Chrome 的
chrome://extensions 加载该目录后一键导出,再在这里选择备份文件,立即生效。
</div>
</div>
{imported && (
<div className="mb-3 rounded border border-line bg-inset px-2.5 py-2">
<div className="flex items-center gap-2">
<span className="shrink-0 text-[11px] text-ink-3">已导入</span>
<span className="min-w-0 flex-1 truncate text-[12px] text-ink">
{imported.browser}
{imported.profile && <span> · {imported.profile}</span>}
{imported.profileDir && <span className="ml-1.5 text-[10.5px] text-ink-3">{imported.profileDir}</span>}
</span>
{imported.sourceId && imported.profileDir && (
<button
type="button"
disabled={busy}
onClick={() => runImport({ sourceId: imported.sourceId!, profileDir: imported.profileDir! })}
className="flex shrink-0 items-center gap-1 rounded border border-line-strong px-2 py-0.5 text-[11px] text-ink-2 transition-colors hover:bg-hover hover:text-ink disabled:opacity-40"
>
<LuRefreshCw size={11} className={busy ? 'animate-spin' : undefined} />
同步
</button>
)}
</div>
<div className="mt-1 text-[10.5px] text-ink-3">
{formatAt(imported.at)}
{imported.cookieCount ? ` · Cookie ${imported.cookieCount} 条` : imported.cookies ? ' · 含 Cookie' : ''}
{imported.localStorage ? ' · LocalStorage' : ''}
</div>
{imported.note && <div className="mt-1 text-[10.5px] leading-relaxed text-ink-3">{imported.note}</div>}
</div>
)}
{sources === null && !error && <p className="py-6 text-center text-ink-3">正在检测本机浏览器…</p>}
{sources?.length === 0 && (
<p className="py-6 text-center text-ink-3">未检测到可导入的本机浏览器(支持 Chrome / Edge / Brave / Chromium)</p>
)}
<div className="flex flex-col gap-2">
{sources?.map((source) => (
<div key={source.id} className="rounded border border-line">
<div className="flex items-center gap-1.5 px-2.5 py-2 text-[12px] font-medium text-ink-2">
<LuChevronDown size={12} className="text-ink-3" />
{source.name}
<span className="text-[10.5px] font-normal text-ink-3">({source.profiles.length} 个配置)</span>
</div>
<div className="flex flex-col gap-1 px-2 pb-2">
{source.profiles.map((profile) => {
const active = selected?.sourceId === source.id && selected.profileDir === profile.dir
return (
<button
key={profile.dir}
type="button"
onClick={() => setSelected({ sourceId: source.id, profileDir: profile.dir })}
className={`flex items-center justify-between rounded px-2.5 py-1.5 text-left text-[11.5px] transition-colors ${
active ? 'bg-accent-tint text-accent-ink' : 'text-ink-2 hover:bg-hover'
}`}
>
<span className="min-w-0 truncate">
{profile.name}
<span className="ml-1.5 text-[10.5px] text-ink-3">{profile.dir}</span>
</span>
{active && <span className="shrink-0 text-[10px]">已选择</span>}
</button>
)
})}
</div>
</div>
))}
</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>
)}
{result && (
<div
className={`mt-3 rounded border px-2.5 py-2 text-[11.5px] leading-relaxed ${
result.ok ? 'border-green-200 bg-green-tint text-green' : 'border-red/40 bg-red-tint text-red'
}`}
>
{result.message}
</div>
)}
<div className="mt-3 flex items-center justify-end">
<button
type="button"
disabled={!selected || busy}
onClick={() => runImport(selected)}
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>
)
}
@@ -4,7 +4,6 @@ import {
LuArrowRight, LuArrowRight,
LuChevronDown, LuChevronDown,
LuCircleAlert, LuCircleAlert,
LuDownload,
LuGlobe, LuGlobe,
LuNetwork, LuNetwork,
LuPlus, LuPlus,
@@ -13,14 +12,13 @@ import {
} from 'react-icons/lu' } from 'react-icons/lu'
import type { BrowserUiState } from '@shared/types' import type { BrowserUiState } from '@shared/types'
import { BrowserSlot } from './BrowserSlot' import { BrowserSlot } from './BrowserSlot'
import { BrowserImportForm } from './BrowserImportForm'
import { BrowserProxyForm } from './BrowserProxyForm' import { BrowserProxyForm } from './BrowserProxyForm'
/** /**
* 内置受管浏览器的「chrome」部分(页签栏/地址栏/工具条/导入浮层)。 * 内置受管浏览器的「chrome」部分(页签栏/地址栏/工具条/代理浮层)。
* 作为侧边面板的一个标签内容渲染;页面本体是主进程的原生 WebContentsView * 作为侧边面板的一个标签内容渲染;页面本体是主进程的原生 WebContentsView
* (盖在 DOM 之上),BrowserSlot 量出页面区域矩形后由主进程对齐原生视图。 * (盖在 DOM 之上),BrowserSlot 量出页面区域矩形后由主进程对齐原生视图。
* 任何全屏浮层(设置/搜索/导入)出现时必须卸载 Slot 隐藏原生视图。 * 任何全屏浮层(设置/搜索/代理)出现时必须卸载 Slot 隐藏原生视图。
*/ */
interface BrowserPanelProps { interface BrowserPanelProps {
@@ -43,7 +41,6 @@ interface BrowserPanelProps {
export function BrowserPanel(props: BrowserPanelProps): JSX.Element { export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
const { state, suspended } = props const { state, suspended } = props
const [address, setAddress] = useState('') const [address, setAddress] = useState('')
const [importOpen, setImportOpen] = useState(false)
const [proxyOpen, setProxyOpen] = useState(false) const [proxyOpen, setProxyOpen] = useState(false)
const [tabListOpen, setTabListOpen] = useState(false) const [tabListOpen, setTabListOpen] = useState(false)
const [tabMenu, setTabMenu] = useState<{ x: number; y: number; tabId: string } | null>(null) const [tabMenu, setTabMenu] = useState<{ x: number; y: number; tabId: string } | null>(null)
@@ -82,7 +79,7 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
const riskBanner = state && !state.riskAcknowledged const riskBanner = state && !state.riskAcknowledged
// 下拉/右键菜单会伸到页面区域上方,原生视图必须暂时让位(原生层级永远在 DOM 之上) // 下拉/右键菜单会伸到页面区域上方,原生视图必须暂时让位(原生层级永远在 DOM 之上)
const overlayOpen = importOpen || proxyOpen || tabListOpen || tabMenu !== null const overlayOpen = proxyOpen || tabListOpen || tabMenu !== null
// 需要暂时隐藏原生视图的原因(宿主全屏浮层 + 本面板菜单) // 需要暂时隐藏原生视图的原因(宿主全屏浮层 + 本面板菜单)
const hideCause = suspended || overlayOpen const hideCause = suspended || overlayOpen
@@ -356,14 +353,6 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
className="min-w-0 flex-1 bg-transparent text-[12px] text-ink-2 outline-none placeholder:text-ink-3" className="min-w-0 flex-1 bg-transparent text-[12px] text-ink-2 outline-none placeholder:text-ink-3"
/> />
</div> </div>
<button
type="button"
onClick={() => setImportOpen(true)}
title="导入本机浏览器登录态"
className="flex size-7 items-center justify-center rounded text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
>
<LuDownload size={13} />
</button>
<button <button
type="button" type="button"
onClick={() => setProxyOpen(true)} onClick={() => setProxyOpen(true)}
@@ -415,17 +404,12 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
</div> </div>
{/* Agent 操作轨迹 */} {/* Agent 操作轨迹 */}
{state?.trace && !importOpen && ( {state?.trace && (
<div className="shrink-0 truncate border-t border-line bg-surface px-3 py-1.5 text-[10.5px] text-ink-3"> <div className="shrink-0 truncate border-t border-line bg-surface px-3 py-1.5 text-[10.5px] text-ink-3">
{state.trace} {state.trace}
</div> </div>
)} )}
{/* 配置导入浮层(覆盖面板,出现时原生视图已隐藏) */}
{importOpen && (
<ImportOverlay onClose={() => setImportOpen(false)} />
)}
{/* 代理设置浮层(覆盖面板,出现时原生视图已隐藏) */} {/* 代理设置浮层(覆盖面板,出现时原生视图已隐藏) */}
{proxyOpen && <ProxyOverlay onClose={() => setProxyOpen(false)} />} {proxyOpen && <ProxyOverlay onClose={() => setProxyOpen(false)} />}
</div> </div>
@@ -434,27 +418,6 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
// ---- 浮层薄壳:标题栏 + 关闭;表单本体在共用组件(设置页二级页同样复用) ---- // ---- 浮层薄壳:标题栏 + 关闭;表单本体在共用组件(设置页二级页同样复用) ----
function ImportOverlay({ onClose }: { onClose: () => void }): JSX.Element {
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">
<BrowserImportForm />
</div>
</div>
)
}
function ProxyOverlay({ onClose }: { onClose: () => void }): JSX.Element { function ProxyOverlay({ onClose }: { onClose: () => void }): JSX.Element {
return ( return (
<div className="absolute inset-0 z-20 flex flex-col bg-surface/95 backdrop-blur-[2px]"> <div className="absolute inset-0 z-20 flex flex-col bg-surface/95 backdrop-blur-[2px]">
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import type { BrowserImportResult, BrowserSource, BrowserUiState } from '@shared/types' import type { BrowserUiState } from '@shared/types'
/** /**
* 内置受管浏览器面板状态与操作。 * 内置受管浏览器面板状态与操作。
@@ -16,8 +16,6 @@ export function useBrowser(): {
closeTab: (tabId: string) => void closeTab: (tabId: string) => void
closeBrowser: () => void closeBrowser: () => void
acknowledgeRisk: () => void acknowledgeRisk: () => void
detectSources: () => Promise<BrowserSource[]>
importProfile: (sourceId: string, profileDir: string) => Promise<BrowserImportResult>
} { } {
const [state, setState] = useState<BrowserUiState | null>(null) const [state, setState] = useState<BrowserUiState | null>(null)
@@ -50,8 +48,6 @@ export function useBrowser(): {
selectTab: wrap((tabId: string) => window.api.browserSelectTab(tabId)), selectTab: wrap((tabId: string) => window.api.browserSelectTab(tabId)),
closeTab: wrap((tabId: string) => window.api.browserCloseTab(tabId)), closeTab: wrap((tabId: string) => window.api.browserCloseTab(tabId)),
closeBrowser: wrap(() => window.api.browserClose()), closeBrowser: wrap(() => window.api.browserClose()),
acknowledgeRisk: wrap(() => window.api.browserAcknowledgeRisk()), acknowledgeRisk: wrap(() => window.api.browserAcknowledgeRisk())
detectSources: () => window.api.browserDetectSources(),
importProfile: (sourceId: string, profileDir: string) => window.api.browserImportProfile(sourceId, profileDir)
} }
} }
-9
View File
@@ -2,9 +2,7 @@ import type {
AgentUiEvent, AgentUiEvent,
AppSettings, AppSettings,
AttachmentInfo, AttachmentInfo,
BrowserImportResult,
BrowserProxySettings, BrowserProxySettings,
BrowserSource,
BrowserUiEvent, BrowserUiEvent,
BrowserUiState, BrowserUiState,
ChatResources, ChatResources,
@@ -37,8 +35,6 @@ export type {
SessionMeta, SessionMeta,
SessionState, SessionState,
SessionStatsInfo, SessionStatsInfo,
BrowserImportResult,
BrowserSource,
BrowserUiEvent, BrowserUiEvent,
BrowserUiState BrowserUiState
} }
@@ -114,11 +110,6 @@ declare global {
revision: number revision: number
bounds: { x: number; y: number; width: number; height: number } bounds: { x: number; y: number; width: number; height: number }
}) => Promise<void> }) => Promise<void>
browserDetectSources: () => Promise<BrowserSource[]>
browserImportProfile: (sourceId: string, profileDir: string) => Promise<BrowserImportResult>
browserImportCookiesFile: (path: string) => Promise<BrowserImportResult>
browserOpenExporterFolder: () => Promise<string>
pathForFile: (file: File) => string
browserGetProxy: () => Promise<BrowserProxySettings> browserGetProxy: () => Promise<BrowserProxySettings>
browserSetProxy: (input: BrowserProxySettings) => Promise<BrowserProxySettings> browserSetProxy: (input: BrowserProxySettings) => Promise<BrowserProxySettings>
onBrowserEvent: (callback: (event: BrowserUiEvent) => void) => () => void onBrowserEvent: (callback: (event: BrowserUiEvent) => void) => () => void
-39
View File
@@ -271,19 +271,6 @@ export interface BrowserClosedTab {
url: string url: string
} }
/** 已导入的本地浏览器配置摘要(面板/设置页展示用;sourceId + profileDir 供「同步」一键复导) */
export interface BrowserImportInfo {
sourceId: string
browser: string
profile: string
profileDir: string
at: string
cookies: boolean
localStorage: boolean
cookieCount?: number
note?: string
}
/** 浏览器面板完整状态(事件与查询共用同一结构) */ /** 浏览器面板完整状态(事件与查询共用同一结构) */
export interface BrowserUiState { export interface BrowserUiState {
/** 是否存在受管浏览器会话(有标签页即视为打开) */ /** 是否存在受管浏览器会话(有标签页即视为打开) */
@@ -296,7 +283,6 @@ export interface BrowserUiState {
recentClosed: BrowserClosedTab[] recentClosed: BrowserClosedTab[]
/** 最近一次 Agent 浏览器操作描述(面板底部轨迹) */ /** 最近一次 Agent 浏览器操作描述(面板底部轨迹) */
trace: string | null trace: string | null
imported?: BrowserImportInfo | null
/** 当前代理设置(面板展示与编辑;变更即广播) */ /** 当前代理设置(面板展示与编辑;变更即广播) */
proxy: BrowserProxySettings proxy: BrowserProxySettings
} }
@@ -313,31 +299,6 @@ export interface BrowserProxySettings {
export type BrowserUiEvent = { type: 'state'; state: BrowserUiState } export type BrowserUiEvent = { type: 'state'; state: BrowserUiState }
/** 本地浏览器(Chromium 系)下的一个 profile */
export interface BrowserProfileMeta {
/** profile 目录名(Default / Profile 1 …) */
dir: string
/** 展示名(Preferences 里的名称或目录名) */
name: string
}
/** 检测到的可导入浏览器来源 */
export interface BrowserSource {
id: string
name: string
userDataDir: string
profiles: BrowserProfileMeta[]
}
/** 导入结果(主进程返回;全程无感,无需重启应用;cookieNote 说明跳过原因) */
export interface BrowserImportResult {
ok: boolean
cookies: boolean
localStorage: boolean
cookieNote?: string
message: string
}
export type AgentUiEvent = export type AgentUiEvent =
| { type: 'session_ready'; state: SessionState } | { type: 'session_ready'; state: SessionState }
| { type: 'session_error'; message: string } | { type: 'session_error'; message: string }