feat: 内置受管浏览器(CDP + AX 快照 ref 体系)+ 本地浏览器配置导入

This commit is contained in:
Rick Ye
2026-09-07 11:20:51 +08:00
parent 5dd645b745
commit eafbe8c15d
16 changed files with 2490 additions and 37 deletions
@@ -0,0 +1,256 @@
import { app } from 'electron'
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import type { BrowserImportResult, BrowserProfileMeta, BrowserSource } from '@shared/types'
import { getBrowserController } from './browser-controller'
/**
* 本地浏览器配置导入(复用各网站登录态)。
*
* 实现思路(零解密依赖,纯文件迁移):
* - Local Storage / IndexedDB 在 Chromium 里是明文 leveldb,直接拷入受管分区即可生效。
* - Cookies 用 AES-GCM 加密,密钥经 OS 加密后存于浏览器根目录 Local State
* (os_crypt.encrypted_key)。Windows 上该密钥由 DPAPI(用户级)包裹——同一
* Windows 用户的任意进程都能解开,因此把这份密钥「移植」进本应用的 Local State,
* 本应用内的 Chromium 即可解密拷贝过来的 Cookies(Chrome 127+ 的 app-bound
* 加密 V20 除外,会明确提示不支持)。macOS/Linux 的密钥绑定钥匙串/KWallet,
* Cookies 无法直接迁移,仅迁移 Local Storage。
*/
/** 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')
}
]
/** 受管浏览器分区目录(persist:aso-browser 的落盘位置) */
function partitionDir(): string {
return join(app.getPath('userData'), 'Partitions', 'aso-browser')
}
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 copyFileSafe(from: string, to: string): void {
try {
copyFileSync(from, to)
} catch (err) {
const code = (err as NodeJS.ErrnoException).code
if (code === 'EBUSY' || code === 'EPERM' || code === 'EACCES') {
throw new Error('源浏览器文件被占用:请完全退出对应的浏览器(含后台进程)后重试。')
}
throw err
}
}
/** 拷贝 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) {
copyFileSafe(join(from, file.name), join(to, file.name))
}
return true
}
interface SourceKeyInfo {
key: string | null
/** app-bound(V20)等不可移植场景的说明 */
note?: string
}
/**
* 读取源浏览器 Local State 里的 os_crypt.encrypted_key 并判断可移植性。
* Windows:前缀 DPAPI 可移植;V20 为 app-bound 加密(Chrome/Edge 127+),
* 其他进程无法解开,明确降级。macOS/Linux:密钥在系统钥匙串/KWallet,跳过。
*/
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 encryption),无法第三方迁移 Cookie;已导入 Local Storage,首次访问各站点仍需登录一次。' }
}
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 迁移。' }
}
}
/** 把源密钥移植进本应用根 Local State(首次会备份原文件) */
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')
}
/** 导入指定浏览器的 profile 配置到受管浏览器分区 */
export function importBrowserProfile(sourceId: string, profileDir: string): BrowserImportResult {
const browser = getBrowserController()
if (browser.hasTabs) {
throw new Error('请先关闭内置浏览器的所有标签页,再导入浏览器配置。')
}
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 target = partitionDir()
// 1) Local Storage(明文 leveldb,直接迁移)
let localStorage = false
try {
localStorage = copyLevelDbDir(join(sourceProfile, 'Local Storage', 'leveldb'), join(target, 'Local Storage', 'leveldb'))
} catch (err) {
throw new Error(err instanceof Error ? err.message : `Local Storage 迁移失败: ${String(err)}`)
}
// 2) Cookies + 密钥移植
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 {
transplantKey(keyInfo.key)
mkdirSync(join(target, 'Network'), { recursive: true })
copyFileSafe(cookieDb, join(target, 'Network', 'Cookies'))
for (const suffix of ['-wal', '-shm']) {
if (existsSync(`${cookieDb}${suffix}`)) copyFileSafe(`${cookieDb}${suffix}`, join(target, 'Network', `Cookies${suffix}`))
}
cookies = true
} catch (err) {
cookieNote = err instanceof Error ? err.message : 'Cookie 迁移失败。'
}
} else {
cookieNote = keyInfo.note ?? '源浏览器没有可迁移的 Cookie 数据库。'
}
const parts: string[] = []
if (cookies) parts.push('Cookie(站点登录态)')
if (localStorage) parts.push('LocalStorage')
if (!cookies && !localStorage) {
return { ok: false, cookies, localStorage, cookieNote, message: `未在 ${sourceName}(${dirName})中找到可迁移的登录数据。` }
}
browser.setImportedInfo({
browser: sourceName,
profile: readProfileName(sourceProfile),
at: new Date().toISOString(),
cookies,
localStorage,
note: cookieNote
})
return {
ok: true,
cookies,
localStorage,
cookieNote,
message: `已从 ${sourceName} 导入 ${parts.join(' + ')}。${cookieNote ?? ''}${
cookies ? '若登录态未生效,重启应用后重试。' : ''
}`
}
}