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)——零句柄零锁,文件直接拷入即生效,搁置/重启不再存在。 * 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 { 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) */ function decryptChromeBlob(encrypted: Buffer, key: Buffer): string { const version = encrypted.subarray(0, 3).toString('utf8') if (version !== 'v10' && version !== 'v11') throw new Error(`不支持的 Cookie 加密版本: ${version}`) const nonce = encrypted.subarray(3, 15) const tag = encrypted.subarray(encrypted.length - 16) const data = encrypted.subarray(15, encrypted.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[]; skipped: number } | { kind: 'unsupported'; note: string } /** * 解密暂存的源 Cookies 数据库 → 明文 Cookie 种子列表(之后经会话 API 注入, * 不碰分区文件,天然无锁)。返回 unsupported 表示当前环境无法解密。 */ async function readSourceCookies(root: string, staging: string): Promise { 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 { const rows = db .prepare('SELECT host_key, name, value, encrypted_value, path, is_secure, is_httponly, expires_utc, samesite FROM cookies') .all() const cookies: BrowserCookieSeed[] = [] let skipped = 0 for (const row of rows) { try { const r = row as { host_key: string name: string value: string encrypted_value: Buffer | null path: string 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) { skipped++ 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_utc > 0 ? { expirationDate: r.expires_utc / 1_000_000 - 11_644_473_600 } : {}), ...(mapSameSite(r.samesite) ? { sameSite: mapSameSite(r.samesite) } : {}) }) } catch { skipped++ } } return { kind: 'ok', cookies, skipped } } finally { db.close() } } /** 导入互斥:同一时刻只允许一个导入在跑(分区轮换不是并发安全的) */ let importInFlight = false /** 导入指定浏览器的 profile 配置到受管浏览器分区(无感热应用:无需重启应用,无搁置路径) */ export async function importBrowserProfile(sourceId: string, profileDir: string): Promise { if (importInFlight) throw new Error('已有导入正在进行,请稍候。') importInFlight = true try { return await importBrowserProfileInner(sourceId, profileDir) } finally { importInFlight = false } } async function importBrowserProfileInner(sourceId: string, profileDir: string): Promise { 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 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 }) 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 ?? ''}` } } /** * 启动早期调用(分区尚未被任何页面打开):清理上一代分区目录与残留暂存。 * 失败只记日志(旧目录多留一启动无害,下次再试)。 */ 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 }) }