diff --git a/apps/desktop/src/main/browser/browser-controller.ts b/apps/desktop/src/main/browser/browser-controller.ts index 253c1dd..2bccb11 100644 --- a/apps/desktop/src/main/browser/browser-controller.ts +++ b/apps/desktop/src/main/browser/browser-controller.ts @@ -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)+ 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 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 { + /** 分区轮换(导入无感应用的核心):切换到从未被打开过的下一代分区目录。 + * 旧分区的 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 { - await this.browserSession.cookies.set(details) + /** 导出当前分区全部 Cookie(会话 API 返回明文;分区轮换前快照,导入后写回新分区) */ + async exportCookies(): Promise { + 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 { - await this.browserSession.setProxy( + /** 应用到指定分区会话;切换后断开在途连接,让新规则对已打开的标签立即生效 */ + private async applyProxyTo(session: Session, proxy: BrowserProxySettings): Promise { + 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 { 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, diff --git a/apps/desktop/src/main/browser/browser-profile.ts b/apps/desktop/src/main/browser/browser-profile.ts index 60c35fd..397001b 100644 --- a/apps/desktop/src/main/browser/browser-profile.ts +++ b/apps/desktop/src/main/browser/browser-profile.ts @@ -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)——零句柄零锁,文件直接拷入即生效,搁置/重启不再存在。 + * 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 = {} - if (existsSync(appLocalState)) { - try { - doc = JSON.parse(readFileSync(appLocalState, 'utf8')) as Record - if (!existsSync(backup)) copyFileSync(appLocalState, backup) - } catch { - doc = {} - } - } - const osCrypt = (doc.os_crypt ?? {}) as Record - 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 { +async function readSourceCookies(root: string, staging: string): Promise { 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 { + 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 { +async function importBrowserProfileInner(sourceId: string, profileDir: string): Promise { 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 { + 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 }) +} diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 95ec676..d69a6a9 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -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()) }) diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 20af223..f9cc1d1 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -55,7 +55,6 @@ const api = { getState: (): Promise => ipcRenderer.invoke('agent:state'), versions: (): Promise<{ app: string; electron: string; node: string; chrome: string; user: string }> => ipcRenderer.invoke('app:versions'), - appRelaunch: (): Promise => ipcRenderer.invoke('app:relaunch'), listSessions: (): Promise => ipcRenderer.invoke('sessions:list'), newSession: (): Promise => ipcRenderer.invoke('sessions:new'), openSession: (path: string): Promise => ipcRenderer.invoke('sessions:open', path), diff --git a/apps/desktop/src/renderer/src/components/browser/BrowserImportForm.tsx b/apps/desktop/src/renderer/src/components/browser/BrowserImportForm.tsx index 09a0f45..94c6737 100644 --- a/apps/desktop/src/renderer/src/components/browser/BrowserImportForm.tsx +++ b/apps/desktop/src/renderer/src/components/browser/BrowserImportForm.tsx @@ -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(null) const [sources, setSources] = useState(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 (

- 迁移 Cookie / Local Storage 登录态。导入前请 - 完全退出源浏览器,并关闭本面板所有标签页。 + 迁移 Cookie / Local Storage 登录态,导入后立即生效。导入前请 + 完全退出源浏览器(含后台进程)。

- {state?.imported && ( -
- 当前导入来源:{state.imported.browser} · {state.imported.profile} - {state.imported.cookies ? '(含 Cookie)' : ''} + {imported && ( +
+
+ 已导入 + + {imported.browser} · {imported.profile} + {imported.profileDir && {imported.profileDir}} + + {imported.sourceId && imported.profileDir && ( + + )} +
+
+ {formatAt(imported.at)} + {imported.cookieCount ? ` · Cookie ${imported.cookieCount} 条` : imported.cookies ? ' · 含 Cookie' : ''} + {imported.localStorage ? ' · LocalStorage' : ''} +
+ {imported.note &&
{imported.note}
}
)} @@ -128,26 +160,12 @@ export function BrowserImportForm(): JSX.Element {
)} - {/* 操作区:始终集中在底部一行;被搁置时以「重启并完成导入」为主操作 */} -
- {result?.deferred && ( - - )} +
diff --git a/apps/desktop/src/renderer/src/lib/api.ts b/apps/desktop/src/renderer/src/lib/api.ts index ad6f9be..516ebc6 100644 --- a/apps/desktop/src/renderer/src/lib/api.ts +++ b/apps/desktop/src/renderer/src/lib/api.ts @@ -58,7 +58,6 @@ declare global { stop: () => Promise getState: () => Promise versions: () => Promise<{ app: string; electron: string; node: string; chrome: string; user: string }> - appRelaunch: () => Promise listSessions: () => Promise newSession: () => Promise openSession: (path: string) => Promise diff --git a/apps/desktop/src/shared/types.ts b/apps/desktop/src/shared/types.ts index f3db5a8..44969af 100644 --- a/apps/desktop/src/shared/types.ts +++ b/apps/desktop/src/shared/types.ts @@ -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 =