fix(browser): 导入彻底无感——分区轮换替代文件热替换,搁置/重启路径全部删除

This commit is contained in:
Rick Ye
2026-09-13 00:34:50 +08:00
parent 95044c3a15
commit 06a316b6d1
7 changed files with 331 additions and 331 deletions
@@ -30,14 +30,17 @@ import {
* 渲染层 BrowserSlot 量出占位矩形,经 IPC 同步边界(原生视图天然盖在 DOM 之上)。
* - 页面观察/操作全部走 CDP(webContents.debugger):AX tree 快照 + ref 代际机制
* + Input.dispatch* 真实输入事件;不在页面里执行模拟点击。
* - 登录态隔离在 persist:aso-browser 分区;本地浏览器配置导入由 browser-profile.ts
* 直接迁移分区目录数据(Cookies/Local Storage + 密钥移植)。
* - 登录态隔离在受管分区;本地浏览器配置导入由 browser-profile.ts 完成:
* 分区轮换(aso-browser-g<n>)+ Cookie 会话 API 热注入,全程无需重启应用。
* - 代理设置作用于同一分区会话(session.setProxy):跟随系统 / 直连 / 手动规则
* (Chromium proxyRules 语法 + 绕过列表),持久化进 aso-browser.json,
* 启动即应用、修改后断开在途连接立即生效(对 Agent 的 browser_* 工具同样生效)。
*/
const PARTITION = 'persist:aso-browser'
// 受管分区名(persist: 前缀之外的磁盘目录名):gen 0 为初始名,导入时轮换 +1
function partitionNameOf(gen: number): string {
return gen <= 0 ? 'aso-browser' : `aso-browser-g${gen}`
}
// 新标签页默认地址:google.com 在目标市场(国内)不可达,新开标签会长时间白屏,
// 误以为浏览器故障;用可稳定访问的导航页
const NEW_TAB_URL = 'https://www.baidu.com/'
@@ -75,6 +78,23 @@ interface BrowserSettings {
riskAcknowledged: boolean
imported?: BrowserImportInfo | null
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 {
@@ -323,19 +343,28 @@ export class BrowserController {
private closedTabs: Array<{ title: string; url: string; closedAt: number }> = []
constructor() {
this.browserSession = electronSession.fromPartition(PARTITION)
this.settings = this.loadSettings()
this.browserSession = this.createBrowserSession()
}
/** 当前受管分区名(persist: 之外的磁盘目录名;导入文件迁移与启动清理按它定位) */
partitionName(): string {
return partitionNameOf(this.settings.partitionGen ?? 0)
}
/** 创建/重建受管分区会话并挂上固定策略(UA、权限、下载、代理) */
private createBrowserSession(): Session {
const session = electronSession.fromPartition(`persist:${this.partitionName()}`)
// 诚实的 UA:保留 Chromium token 保证站点兼容,去掉 Electron 标识
this.browserSession.setUserAgent(
this.browserSession.getUserAgent().replace(/Electron\/[\d.]+/, `InkreachAgent/${app.getVersion()}`)
)
session.setUserAgent(session.getUserAgent().replace(/Electron\/[\d.]+/, `InkreachAgent/${app.getVersion()}`))
// 网页权限请求(摄像头/定位等)默认拒绝
this.browserSession.setPermissionRequestHandler((_wc, _permission, callback) => callback(false))
session.setPermissionRequestHandler((_wc, _permission, callback) => callback(false))
// 下载留在受管浏览器内,落到系统下载目录(重名自动加序号)
this.browserSession.on('will-download', (_event, item) => {
session.on('will-download', (_event, item) => {
const dir = app.getPath('downloads')
const base = item.getFilename() || 'download'
const dot = base.lastIndexOf('.')
const stem = dot > 0 ? base.slice(0, dot) : base
const stem = dot > 0 ? base.slice(0, dot) : ''
const ext = dot > 0 ? base.slice(dot) : ''
let target = join(dir, base)
for (let i = 1; existsSync(target) && i < 100; i++) {
@@ -343,11 +372,11 @@ export class BrowserController {
}
item.setSavePath(target)
})
this.settings = this.loadSettings()
// 启动即应用持久化的代理设置(setProxy 异步;失败不阻塞浏览器初始化,日志可查)
this.applyProxy(this.proxySettings()).catch((err: unknown) => {
this.applyProxyTo(session, this.proxySettings()).catch((err: unknown) => {
log.error('启动时代理设置应用失败:', { err })
})
return session
}
// ---- 设置持久化 ----
@@ -360,16 +389,20 @@ export class BrowserController {
try {
const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as Partial<BrowserSettings>
const proxy = raw.proxy
const imported = raw.imported
// 旧版「搁置/需重启」类提示是一次性状态说明,不该常驻展示——历史残留直接清掉
if (imported?.note && /被占用|暂存|搁置|重启/.test(imported.note)) imported.note = undefined
return {
riskAcknowledged: raw.riskAcknowledged === true,
imported: raw.imported ?? null,
imported: imported ?? null,
proxy:
proxy && (proxy.mode === 'direct' || proxy.mode === 'fixed' || proxy.mode === 'system')
? proxy
: { mode: 'system' }
: { mode: 'system' },
partitionGen: typeof raw.partitionGen === 'number' && raw.partitionGen >= 0 ? Math.floor(raw.partitionGen) : 0
}
} catch {
return { riskAcknowledged: false, proxy: { mode: 'system' } }
return { riskAcknowledged: false, proxy: { mode: 'system' }, partitionGen: 0 }
}
}
@@ -392,19 +425,27 @@ export class BrowserController {
return this.settings.imported ?? null
}
/** 内核级热重启(导入热应用用):销毁全部标签、清空分区存储后端
* (Chromium 释放 leveldb/cookie 数据句柄),返回被销毁标签的 URL 供恢复 */
async restartKernel(): Promise<string[]> {
/** 分区轮换(导入无感应用的核心):切换到从未被打开过的下一代分区目录。
* 旧分区的 leveldb / Cookies 文件句柄在本进程内无法释放(clearStorageData
* 只清数据不关句柄),文件热替换必然 EPERM;新目录零句柄零锁,文件直接
* 写入即生效。旧目录由启动清理回收。 */
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()
await this.browserSession.clearStorageData({ storages: ['localstorage', 'cookies', 'indexdb'] })
return urls
}
/** 恢复内核热重启前打开的标签 */
/** 恢复导入前打开的标签 */
restoreTabs(urls: string[]): void {
for (const url of urls) {
try {
@@ -415,9 +456,48 @@ export class BrowserController {
}
}
/** 热注入单条 Cookie(导入路径:源数据经解密后逐条写入分区会话) */
async setCookie(details: Electron.CookiesSetDetails): Promise<void> {
await this.browserSession.cookies.set(details)
/** 导出当前分区全部 Cookie(会话 API 返回明文;分区轮换前快照,导入后写回新分区) */
async exportCookies(): Promise<BrowserCookieSeed[]> {
const list = await this.browserSession.cookies.get({})
return list.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 } : {})
}))
}
/** 批量写入当前分区会话(不落文件、天然无锁;分批并发控制在对会话的冲击) */
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 }
}
// ---- 代理设置 ----
@@ -430,33 +510,28 @@ export class BrowserController {
return this.proxySettings()
}
/** 应用到分区会话;切换后断开在途连接,让新规则对已打开的标签立即生效 */
private async applyProxy(proxy: BrowserProxySettings): Promise<void> {
await this.browserSession.setProxy(
/** 应用到指定分区会话;切换后断开在途连接,让新规则对已打开的标签立即生效 */
private async applyProxyTo(session: Session, proxy: BrowserProxySettings): Promise<void> {
await session.setProxy(
proxy.mode === 'fixed'
? { mode: 'fixed_servers', proxyRules: proxy.rules ?? '', proxyBypassRules: proxy.bypass }
: proxy.mode === 'direct'
? { mode: 'direct' }
: { mode: 'system' }
)
await this.browserSession.closeAllConnections()
await session.closeAllConnections()
}
/** 更新并持久化代理设置:先应用成功才落盘(失败抛错给 UI,原设置保持不变) */
async setProxySettings(input: BrowserProxySettings): Promise<BrowserProxySettings> {
const next = normalizeProxy(input)
await this.applyProxy(next)
await this.applyProxyTo(this.browserSession, next)
this.settings.proxy = next
this.saveSettings()
this.emitState()
return next
}
/** 当前是否有活跃标签页(导入配置前必须没有) */
get hasTabs(): boolean {
return this.tabs.size > 0
}
// ---- 窗口与状态广播 ----
setOwnerWindow(win: BrowserWindow): void {
@@ -560,16 +635,20 @@ export class BrowserController {
if (!this.owner || this.owner.isDestroyed()) throw new Error('主窗口尚未就绪,无法创建浏览器标签。')
if (this.tabs.size >= MAX_TABS) throw new Error(`浏览器标签页已达上限(${MAX_TABS}),请先关闭不需要的标签。`)
const tabId = `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
// popup 的 viewOptions 不一定带 partition(会导致弹窗落入默认会话),统一强制当前受管分区
const partition = `persist:${this.partitionName()}`
const view = new WebContentsView(
viewOptions ?? {
webPreferences: {
partition: PARTITION,
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
webSecurity: true
}
}
viewOptions
? { ...viewOptions, webPreferences: { ...(viewOptions.webPreferences ?? {}), partition } }
: {
webPreferences: {
partition,
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
webSecurity: true
}
}
)
const tab: TabRecord = {
tabId,
+157 -247
View File
@@ -1,31 +1,30 @@
import { app } from 'electron'
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
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 } from './browser-controller'
import { getBrowserController, type BrowserCookieSeed } from './browser-controller'
import { getLogger } from '../logger'
const log = getLogger('browser-profile')
/**
* 本地浏览器配置导入(复用各网站登录态)。
* 本地浏览器配置导入(复用各网站登录态)——全程无感,无需重启应用:
*
* 迁移策略(collect → apply 两段式):
* 1) collect:源 profile 的 Local Storage(明文 leveldb)与 Cookies 数据库先拷入
* 暂存目录——此刻把数据抓到手,源浏览器之后锁不锁都无所谓;
* Windows 上同时把源 os_crypt 密钥(DPAPI 包裹)移植进本应用 Local State。
* 2) apply 热路径(无需重启应用):
* - Local Storage:文件迁移;分区被本应用 Chromium 锁住时执行「内核热重启」
* (销毁全部标签 + clearStorageData 释放存储句柄)后重试,随后自动恢复标签;
* - Cookies:用 DPAPI 解出的密钥自行解密源 Cookies 数据库,逐条经
* session.cookies.set 热注入(文件替换在进程存活期间永远不可行——
* CookieManager 与内存 CookieJar 都会覆盖它)。
* 3) 兜底:Cookie 无法解密(非 Windows / app-bound V20 / DPAPI 失败)时,
* Cookies 文件转待办任务(pending),下次启动早期自动落盘生效。
* 暂存目录,源数据全部抓到手;Windows 上用 DPAPI 解出源 Cookie 密钥,
* 自行解密暂存 Cookies 库得到明文 Cookie 列表。
* 2) 分区轮换:受管分区一旦被本应用 Chromium 打开过,其 leveldb / Cookies 文件
* 句柄在本进程内无法释放(clearStorageData 只清数据不关句柄),对存活分区
* 做文件热替换必然 EPERM。因此导入时切换到「从未打开过」的下一代分区目录
* (aso-browser-g<n>)——零句柄零锁,文件直接拷入即生效,搁置/重启不再存在。
* 3) Cookie 走会话 API:受管浏览器自身登录态先从旧会话导出(cookies.get 返回
* 明文)再写回新分区,源 Cookie 解密后逐条注入。全程不碰分区文件。
* 4) 旧分区目录在下次启动早期清理(那时无人持有句柄,见 cleanupStaleBrowserPartitions)。
*/
/** Chromium 系浏览器的用户数据根目录(按平台) */
@@ -60,18 +59,16 @@ const SOURCE_ROOTS: Array<{ id: string; name: string; win: string; mac: string;
}
]
/** 受管浏览器分区目录(persist:aso-browser 的落盘位置) */
function partitionDir(): string {
return join(app.getPath('userData'), 'Partitions', 'aso-browser')
/** 所有受管分区目录的根(userData/Partitions) */
function partitionsRoot(): string {
return join(app.getPath('userData'), 'Partitions')
}
/** 待办导入目录(分区被占用时的暂存转正;启动早期自动应用) */
function pendingDir(): string {
return join(app.getPath('userData'), 'Partitions', 'aso-browser.pending-import')
/** 指定分区名的磁盘目录 */
function partitionDirOf(name: string): string {
return join(partitionsRoot(), name)
}
const IMPORT_META_FILE = 'import-meta.json'
function sourceRoot(id: string): string {
const source = SOURCE_ROOTS.find((s) => s.id === id)
if (!source) return ''
@@ -141,15 +138,15 @@ function copyLevelDbDir(from: string, to: string): boolean {
interface SourceKeyInfo {
key: string | null
/** app-bound(V20)等不可移植场景的说明 */
/** 不可解密场景(非 Windows / app-bound V20 / 缺失)的说明 */
note?: string
}
/**
* 读取源浏览器 Local State 里的 os_crypt.encrypted_key 并判断可移植性。
* Windows:前缀 DPAPI 可移植(文件兜底路径靠移植它;注入路径用它解密);
* V20 为 app-bound 加密(Chrome/Edge 127+),明确降级。
* macOS/Linux:密钥在系统钥匙串/KWallet,Cookies 无法解密,仅迁移 Local Storage。
* 读取源浏览器 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') {
@@ -163,7 +160,7 @@ function readSourceKey(userDataDir: string): SourceKeyInfo {
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 encryption),无法第三方迁移 Cookie;已导入 Local Storage,首次访问各站点仍需登录一次。' }
return { key: null, note: '该浏览器启用了应用绑定加密(app-bound),Cookie 无法第三方迁移;LocalStorage 已导入,需登录的站点在受管浏览器里登录一次即可。' }
}
if (bytes.subarray(0, 5).toString('utf8') !== 'DPAPI') {
return { key: null, note: '源浏览器 Cookie 密钥格式不受支持,跳过 Cookie 迁移。' }
@@ -214,36 +211,24 @@ function decryptChromeBlob(encrypted: Buffer, key: Buffer): string {
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8')
}
/** 把源密钥移植进本应用根 Local State(首次会备份原文件;Cookie 文件兜底路径需要它) */
function transplantKey(sourceKey: string): void {
const appLocalState = join(app.getPath('userData'), 'Local State')
const backup = `${appLocalState}.aso-backup`
let doc: Record<string, unknown> = {}
if (existsSync(appLocalState)) {
try {
doc = JSON.parse(readFileSync(appLocalState, 'utf8')) as Record<string, unknown>
if (!existsSync(backup)) copyFileSync(appLocalState, backup)
} catch {
doc = {}
}
}
const osCrypt = (doc.os_crypt ?? {}) as Record<string, unknown>
osCrypt.encrypted_key = sourceKey
doc.os_crypt = osCrypt
writeFileSync(appLocalState, JSON.stringify(doc, null, 2), '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
/** 已移植源密钥(Cookie 文件兜底路径在重启后由本应用 Chromium 解密需要它) */
transplanted: boolean
}
/**
* collect:源 profile → 暂存目录(分区布局:Local Storage/leveldb + Network/Cookies)。
* 源文件被占用时抛出可操作的提示;Cookie 失败不阻塞(降级只导 Local Storage)。
* collect:源 profile → 暂存目录(Local Storage/leveldb + Network/Cookies)。
* 源文件被占用时抛出可操作的提示;Cookie 拷贝失败不阻塞(降级只导 Local Storage)。
*/
function collectFromSource(root: string, sourceProfile: string, staging: string): CollectedImport {
let localStorage = false
@@ -258,7 +243,6 @@ function collectFromSource(root: string, sourceProfile: string, staging: string)
let cookies = false
let cookieNote: string | undefined
let transplanted = false
const keyInfo = readSourceKey(root)
const cookieDb = existsSync(join(sourceProfile, 'Network', 'Cookies'))
? join(sourceProfile, 'Network', 'Cookies')
@@ -267,8 +251,6 @@ function collectFromSource(root: string, sourceProfile: string, staging: string)
: null
if (keyInfo.key && cookieDb) {
try {
transplantKey(keyInfo.key)
transplanted = true
mkdirSync(join(staging, 'Network'), { recursive: true })
copyFile(cookieDb, join(staging, 'Network', 'Cookies'))
for (const suffix of ['-wal', '-shm']) {
@@ -286,53 +268,42 @@ function collectFromSource(root: string, sourceProfile: string, staging: string)
cookieNote = keyInfo.note ?? '源浏览器没有可迁移的 Cookie 数据库。'
}
return { cookies, localStorage, cookieNote, transplanted }
return { cookies, localStorage, cookieNote }
}
/** apply(LocalStorage):暂存 → 受管分区(分区被本应用 Chromium 锁住时抛原始 lock 错误) */
function applyStagedLocalStorage(staging: string): void {
copyLevelDbDir(join(staging, 'Local Storage', 'leveldb'), join(partitionDir(), 'Local Storage', 'leveldb'))
/** apply(LocalStorage):暂存 → 指定分区目录(目标应是未被 Chromium 打开过的目录) */
function applyStagedLocalStorage(staging: string, targetPartitionDir: string): void {
copyLevelDbDir(join(staging, 'Local Storage', 'leveldb'), join(targetPartitionDir, 'Local Storage', 'leveldb'))
}
/** apply(Cookies 文件兜底):仅密钥已移植时有效(重启后 Chromium 用移植密钥解密) */
function applyStagedCookies(staging: string): void {
const cookieDb = join(staging, 'Network', 'Cookies')
if (!existsSync(cookieDb)) return
const target = partitionDir()
mkdirSync(join(target, 'Network'), { recursive: true })
copyFile(cookieDb, join(target, 'Network', 'Cookies'))
for (const suffix of ['-wal', '-shm']) {
if (existsSync(`${cookieDb}${suffix}`)) copyFile(`${cookieDb}${suffix}`, join(target, 'Network', `Cookies${suffix}`))
}
}
type CookieInjectResult =
| { kind: 'ok'; injected: number; failed: number }
type SourceCookieRead =
| { kind: 'ok'; cookies: BrowserCookieSeed[]; skipped: number }
| { kind: 'unsupported'; note: string }
/**
* Cookie 热注入:解密暂存 Cookies 数据库并逐条写入分区会话(不碰文件,无需重启)。
* 返回 unsupported 表示当前环境无法解密(非 Windows / 无密钥 / DPAPI 失败)。
* 解密暂存的源 Cookies 数据库 → 明文 Cookie 种子列表(之后经会话 API 注入,
* 不碰分区文件,天然无锁)。返回 unsupported 表示当前环境无法解密。
*/
async function injectCookiesFromStaging(root: string, staging: string): Promise<CookieInjectResult> {
async function readSourceCookies(root: string, staging: string): Promise<SourceCookieRead> {
if (process.platform !== 'win32') {
return { kind: 'unsupported', note: '当前平台 Cookie 密钥绑定系统钥匙串,Cookies 将以文件方式在重启后自动落盘。' }
return { kind: 'unsupported', note: '当前平台的浏览器 Cookie 密钥绑定系统钥匙串,暂不支持迁移 Cookie(LocalStorage 登录态仍会导入)。' }
}
const keyInfo = readSourceKey(root)
if (!keyInfo.key) return { kind: 'unsupported', note: keyInfo.note ?? '无可用 Cookie 密钥,跳过 Cookie 热注入。' }
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),Cookies 将以文件方式在重启后自动落盘。' }
return { kind: 'unsupported', note: 'Cookie 密钥解密失败(DPAPI),本次未导入 Cookie,可稍后重试。' }
}
const db = new DatabaseSync(join(staging, 'Network', 'Cookies'), { readOnly: true })
const db = new DatabaseSync(dbFile, { readOnly: true })
try {
const rows = db
.prepare('SELECT host_key, name, value, encrypted_value, path, is_secure, is_httponly, expires_utc FROM cookies')
.prepare('SELECT host_key, name, value, encrypted_value, path, is_secure, is_httponly, expires_utc, samesite FROM cookies')
.all()
const controller = getBrowserController()
let injected = 0
let failed = 0
const cookies: BrowserCookieSeed[] = []
let skipped = 0
for (const row of rows) {
try {
const r = row as {
@@ -344,67 +315,52 @@ async function injectCookiesFromStaging(root: string, staging: string): Promise<
is_secure: number
is_httponly: number
expires_utc: number
samesite: number | null
}
let value = r.value
if (!value && r.encrypted_value && r.encrypted_value.length > 3) {
value = decryptChromeBlob(r.encrypted_value, key)
}
if (!value) {
failed++
skipped++
continue
}
const secure = r.is_secure === 1
const details: Electron.CookiesSetDetails = {
url: `${secure ? 'https' : 'http'}://${r.host_key.replace(/^\./, '')}${r.path || '/'}`,
cookies.push({
name: r.name,
value,
domain: r.host_key,
hostOnly: !r.host_key.startsWith('.'),
path: r.path || '/',
secure,
httpOnly: r.is_httponly === 1
}
if (r.expires_utc > 0) details.expirationDate = r.expires_utc / 1_000_000 - 11_644_473_600
await controller.setCookie(details)
injected++
secure: r.is_secure === 1,
httpOnly: r.is_httponly === 1,
...(r.expires_utc > 0 ? { expirationDate: r.expires_utc / 1_000_000 - 11_644_473_600 } : {}),
...(mapSameSite(r.samesite) ? { sameSite: mapSameSite(r.samesite) } : {})
})
} catch {
failed++
skipped++
}
}
return { kind: 'ok', injected, failed }
return { kind: 'ok', cookies, skipped }
} finally {
db.close()
}
}
/**
* 应用启动早期调用(分区尚未被 Chromium 打开):完成上次 Cookie 文件兜底
* 路径搁置的待办导入。失败则保留待办,下次启动重试。
*/
export function applyPendingProfileImport(): void {
const pending = pendingDir()
const metaFile = join(pending, IMPORT_META_FILE)
if (!existsSync(metaFile)) return
/** 导入互斥:同一时刻只允许一个导入在跑(分区轮换不是并发安全的) */
let importInFlight = false
/** 导入指定浏览器的 profile 配置到受管浏览器分区(无感热应用:无需重启应用,无搁置路径) */
export async function importBrowserProfile(sourceId: string, profileDir: string): Promise<BrowserImportResult> {
if (importInFlight) throw new Error('已有导入正在进行,请稍候。')
importInFlight = true
try {
const meta = JSON.parse(readFileSync(metaFile, 'utf8')) as BrowserImportInfo
applyStagedLocalStorage(pending)
applyStagedCookies(pending)
rmSync(pending, { recursive: true, force: true })
getBrowserController().setImportedInfo({ ...meta, note: undefined })
log.info('pending profile import applied', { browser: meta.browser, profile: meta.profile })
} catch (err) {
if (err instanceof SyntaxError) {
// meta 损坏:无法解读的待办只能放弃
rmSync(pending, { recursive: true, force: true })
log.error('pending profile import meta corrupted, dropped', { err: err instanceof Error ? err.message : String(err) })
return
}
// 分区仍被占用等暂时性失败:保留待办,下次启动重试
log.warn('pending profile import deferred', { err: err instanceof Error ? err.message : String(err) })
return await importBrowserProfileInner(sourceId, profileDir)
} finally {
importInFlight = false
}
}
/** 导入指定浏览器的 profile 配置到受管浏览器分区(热应用:通常无需重启应用) */
export async function importBrowserProfile(sourceId: string, profileDir: string): Promise<BrowserImportResult> {
async function importBrowserProfileInner(sourceId: string, profileDir: string): Promise<BrowserImportResult> {
const browser = getBrowserController()
const root = sourceRoot(sourceId)
if (!root || !existsSync(root)) throw new Error('未找到该浏览器的用户数据目录。')
@@ -417,8 +373,8 @@ export async function importBrowserProfile(sourceId: string, profileDir: string)
const sourceName = SOURCE_ROOTS.find((s) => s.id === sourceId)?.name ?? sourceId
const profileName = readProfileName(sourceProfile)
// 1) collect:源 → 暂存目录(源数据先抓到手;Cookies 文件作为解密源与兜底)
const staging = join(app.getPath('userData'), 'Partitions', `.import-staging-${Date.now()}`)
// 1) collect + 源 Cookie 解密(源数据全部先抓到手,之后源浏览器锁不锁都无所谓)
const staging = join(partitionsRoot(), `.import-staging-${Date.now()}`)
let collected: CollectedImport
try {
mkdirSync(staging, { recursive: true })
@@ -427,144 +383,98 @@ export async function importBrowserProfile(sourceId: string, profileDir: string)
rmSync(staging, { recursive: true, force: true })
throw err
}
const sourceRead: SourceCookieRead = collected.cookieNote
? { kind: 'unsupported', note: collected.cookieNote }
: await readSourceCookies(root, staging)
// 2) Local Storage 热应用:被分区占用时执行内核热重启(销毁标签 + 释放存储句柄)
let restarted = false
// 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)
applyStagedLocalStorage(staging, partitionDirOf(browser.partitionName()))
} catch (err) {
if (!isLockError(err)) {
rmSync(staging, { recursive: true, force: true })
throw new Error(`导入写入失败: ${err instanceof Error ? err.message : String(err)}`)
}
const urls = await browser.restartKernel()
restarted = true
try {
applyStagedLocalStorage(staging)
} catch (err2) {
// 理论不可达(clearStorageData 已释放句柄);兜底:整体转待办,重启应用后完成
const deferredNote = '已暂存导入任务:点击下方「重启并完成导入」即可生效。'
mkdirSync(join(app.getPath('userData'), 'Partitions'), { recursive: true })
rmSync(pendingDir(), { recursive: true, force: true })
writeFileSync(
join(staging, IMPORT_META_FILE),
JSON.stringify(
{ browser: sourceName, profile: profileName, at: new Date().toISOString(), cookies: collected.cookies, localStorage: true, note: deferredNote },
null,
2
),
'utf8'
)
renameSync(staging, pendingDir())
browser.setImportedInfo({
browser: sourceName,
profile: profileName,
at: new Date().toISOString(),
cookies: collected.cookies,
localStorage: true,
note: deferredNote
})
browser.restoreTabs(urls)
log.warn('partition still locked after kernel restart, deferred', { err: err2 instanceof Error ? err2.message : String(err2) })
return { ok: true, deferred: true, cookies: collected.cookies, localStorage: true, cookieNote: deferredNote, message: deferredNote }
}
}
// 3) Cookie:优先解密热注入(无需重启);不可解密时走文件兜底/待办
const inject = await injectCookiesFromStaging(root, staging)
if (inject.kind === 'unsupported' && collected.transplanted) {
// 密钥已移植:Cookie 文件兜底可拷(登录态在应用重启、Chromium 重读密钥后生效)
try {
applyStagedCookies(staging)
} catch (err) {
if (!isLockError(err)) {
rmSync(staging, { recursive: true, force: true })
throw new Error(`导入写入失败: ${err instanceof Error ? err.message : String(err)}`)
}
const deferredNote = 'LocalStorage 已生效;点击「重启并完成导入」补上 Cookie。'
mkdirSync(join(app.getPath('userData'), 'Partitions'), { recursive: true })
rmSync(pendingDir(), { recursive: true, force: true })
writeFileSync(
join(staging, IMPORT_META_FILE),
JSON.stringify(
{ browser: sourceName, profile: profileName, at: new Date().toISOString(), cookies: true, localStorage: true, note: deferredNote },
null,
2
),
'utf8'
)
renameSync(staging, pendingDir())
browser.setImportedInfo({
browser: sourceName,
profile: profileName,
at: new Date().toISOString(),
cookies: true,
localStorage: true,
note: deferredNote
})
if (restarted) browser.restoreTabs([])
log.warn('cookie file locked, deferred to next launch', { source: sourceId, profile: dirName })
return { ok: true, deferred: true, cookies: false, localStorage: true, cookieNote: deferredNote, message: deferredNote }
}
browser.restoreTabs(urls)
rmSync(staging, { recursive: true, force: true })
if (restarted) browser.restoreTabs([])
browser.setImportedInfo({
browser: sourceName,
profile: profileName,
at: new Date().toISOString(),
cookies: true,
localStorage: collected.localStorage,
note: collected.cookieNote
})
return {
ok: true,
cookies: true,
localStorage: collected.localStorage,
cookieNote: collected.cookieNote,
message: `已从 ${sourceName} 导入 Cookie + LocalStorage${restarted ? '(浏览器内核已热重启)' : ''}。若登录态未生效,重启应用后重试。`
}
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
const skipped = result.failed + sourceRead.skipped
if (skipped > 0) cookieNote = `源 Cookie ${injected} 条已注入,${skipped} 条无法解密已跳过。`
} 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 })
if (restarted) browser.restoreTabs([])
if (inject.kind === 'ok') {
const cookies = inject.injected > 0
const cookieNote =
inject.failed > 0 ? `Cookie ${inject.injected} 条注入成功,${inject.failed} 条解密失败已跳过。` : undefined
browser.setImportedInfo({
browser: sourceName,
profile: profileName,
at: new Date().toISOString(),
cookies,
localStorage: true,
note: cookieNote
})
return {
ok: true,
cookies,
localStorage: true,
cookieNote,
message: `已从 ${sourceName} 热导入 LocalStorage + Cookie ${inject.injected} 条${
inject.failed > 0 ? `(${inject.failed} 条跳过)` : ''
}(浏览器内核已热重启,无需重启应用)。`
}
}
// 无密钥/不支持:只有 LocalStorage 生效
browser.setImportedInfo({
const info: BrowserImportInfo = {
sourceId,
browser: sourceName,
profile: profileName,
profileDir: dirName,
at: new Date().toISOString(),
cookies: false,
localStorage: true,
note: inject.note
})
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: false,
localStorage: true,
cookieNote: inject.note,
message: `已从 ${sourceName} 导入 LocalStorage。${inject.note ?? ''}`
cookies: injected > 0,
localStorage: collected.localStorage,
...(cookieNote ? { cookieNote } : {}),
message: `已从 ${sourceName}「${profileName}」导入:${summary}。${urls.length > 0 ? '原标签页已自动恢复。' : ''}${cookieNote ?? ''}`
}
}
/**
* 启动早期调用(分区尚未被任何页面打开):清理上一代分区目录与残留暂存。
* 失败只记日志(旧目录多留一启动无害,下次再试)。
*/
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 })
}
+3 -9
View File
@@ -4,7 +4,7 @@ import { userInfo } from 'node:os'
import { AgentService } from './pi/agent-service'
import { getPluginHost } from './plugins/plugin-host'
import { getBrowserController } from './browser/browser-controller'
import { applyPendingProfileImport, detectBrowserSources, importBrowserProfile } from './browser/browser-profile'
import { cleanupStaleBrowserPartitions, detectBrowserSources, importBrowserProfile } from './browser/browser-profile'
import { getLogger } from './logger'
const log = getLogger('ipc')
@@ -274,12 +274,6 @@ export function registerIpc(): AgentService {
return Promise.resolve()
})
// 应用自重启(浏览器数据导入被搁置时的一键完成入口)
handle('app:relaunch', () => {
app.relaunch()
app.exit(0)
})
handle('app:versions', () => ({
app: process.env.npm_package_version ?? '0.0.0',
electron: process.versions.electron,
@@ -428,8 +422,8 @@ export function registerIpc(): AgentService {
// ---- 插件系统 ----
void app.whenReady().then(() => {
// 上次搁置的浏览器数据导入在此刻应用(分区尚未被 Chromium 打开,文件必然可写)
applyPendingProfileImport()
// 清理上一代受管分区与残留暂存(此刻无人持有句柄;导入已无搁置路径)
void cleanupStaleBrowserPartitions()
// 插件启停/重载会重建 agent 运行时(extensionFactories 重跑)
getPluginHost().setRuntimeRebuilder(() => service.rebuildRuntime())
})
-1
View File
@@ -55,7 +55,6 @@ const api = {
getState: (): Promise<SessionState> => ipcRenderer.invoke('agent:state'),
versions: (): Promise<{ app: string; electron: string; node: string; chrome: string; user: string }> =>
ipcRenderer.invoke('app:versions'),
appRelaunch: (): Promise<void> => ipcRenderer.invoke('app:relaunch'),
listSessions: (): Promise<SessionMeta[]> => ipcRenderer.invoke('sessions:list'),
newSession: (): Promise<void> => ipcRenderer.invoke('sessions:new'),
openSession: (path: string): Promise<void> => ipcRenderer.invoke('sessions:open', path),
@@ -2,12 +2,20 @@
* 本机浏览器登录态导入表单(面板浮层与设置页二级页共用)。
* 选择本机浏览器(Chrome/Edge/Brave/Chromium)的一个 Profile,
* 把各站点登录态(Cookie / Local Storage)迁移到受管浏览器分区。
* 导入全程无感:无需重启应用;已导入的来源常驻展示,可一键「同步」复导。
* 自含数据源:直接调用 window.api(browserDetectSources / browserImportProfile)。
*/
import { useEffect, useState, type JSX } from 'react'
import { LuChevronDown } from 'react-icons/lu'
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)
@@ -51,29 +59,53 @@ export function BrowserImportForm(): JSX.Element {
}
}, [])
const runImport = (): void => {
if (!selected) return
const runImport = (target: { sourceId: string; profileDir: string } | null): void => {
if (!target || busy) return
setBusy(true)
setError(null)
setResult(null)
window.api
.browserImportProfile(selected.sourceId, selected.profileDir)
.browserImportProfile(target.sourceId, target.profileDir)
.then(setResult)
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
.finally(() => setBusy(false))
}
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>源浏览器,并关闭本面板所有标签页。
迁移 Cookie / Local Storage 登录态,导入后立即生效。导入前请
<span className="font-medium text-ink-2">完全退出</span>源浏览器(含后台进程)。
</p>
{state?.imported && (
<div className="mb-3 rounded border border-line bg-inset px-2.5 py-2 text-[11.5px] text-ink-3">
当前导入来源:{state.imported.browser} · {state.imported.profile}
{state.imported.cookies ? '(含 Cookie)' : ''}
{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}
{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>
)}
@@ -128,26 +160,12 @@ export function BrowserImportForm(): JSX.Element {
</div>
)}
{/* 操作区:始终集中在底部一行;被搁置时以「重启并完成导入」为主操作 */}
<div className="mt-3 flex items-center justify-end gap-2">
{result?.deferred && (
<button
type="button"
onClick={() => void window.api.appRelaunch()}
className="rounded bg-accent px-3.5 py-1.5 text-[12px] font-medium text-page transition-opacity hover:opacity-90"
>
重启并完成导入
</button>
)}
<div className="mt-3 flex items-center justify-end">
<button
type="button"
disabled={!selected || busy}
onClick={runImport}
className={`rounded px-3.5 py-1.5 text-[12px] transition-colors disabled:opacity-40 ${
result?.deferred
? 'border border-line-strong text-ink-2 hover:bg-hover hover:text-ink'
: 'bg-accent font-medium text-page hover:opacity-90'
}`}
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>
-1
View File
@@ -58,7 +58,6 @@ declare global {
stop: () => Promise<void>
getState: () => Promise<SessionState>
versions: () => Promise<{ app: string; electron: string; node: string; chrome: string; user: string }>
appRelaunch: () => Promise<void>
listSessions: () => Promise<SessionMeta[]>
newSession: () => Promise<void>
openSession: (path: string) => Promise<void>
+5 -4
View File
@@ -271,13 +271,16 @@ export interface BrowserClosedTab {
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
}
@@ -326,15 +329,13 @@ export interface BrowserSource {
profiles: BrowserProfileMeta[]
}
/** 导入结果(主进程返回;cookieNote 说明跳过原因) */
/** 导入结果(主进程返回;全程无感,无需重启应用;cookieNote 说明跳过原因) */
export interface BrowserImportResult {
ok: boolean
cookies: boolean
localStorage: boolean
cookieNote?: string
message: string
/** true = 分区被占用,已转待办:重启应用后自动完成导入 */
deferred?: boolean
}
export type AgentUiEvent =