revert(browser): 移除「导入本机浏览器登录态」功能(保留代理设置)
This commit is contained in:
@@ -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 })
|
|
||||||
}
|
|
||||||
@@ -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,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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user