Files
inkreach-agent/apps/desktop/src/main/browser/browser-profile.ts
T

488 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { app } from 'electron'
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs'
import { rm as rmAsync } from 'node:fs/promises'
import { createDecipheriv } from 'node:crypto'
import { execFile } from 'node:child_process'
import { DatabaseSync } from 'node:sqlite'
import { homedir } from 'node:os'
import { join } from 'node:path'
import type { BrowserImportInfo, BrowserImportResult, BrowserProfileMeta, BrowserSource } from '@shared/types'
import { getBrowserController, type BrowserCookieSeed } from './browser-controller'
import { getLogger } from '../logger'
const log = getLogger('browser-profile')
/**
* 本地浏览器配置导入(复用各网站登录态)——全程无感,无需重启应用:
*
* 1) collect:源 profile 的 Local Storage(明文 leveldb)与 Cookies 数据库先拷入
* 暂存目录,源数据全部抓到手;Windows 上用 DPAPI 解出源 Cookie 密钥,
* 自行解密暂存 Cookies 库得到明文 Cookie 列表。
* 2) 分区轮换:受管分区一旦被本应用 Chromium 打开过,其 leveldb / Cookies 文件
* 句柄在本进程内无法释放(clearStorageData 只清数据不关句柄),对存活分区
* 做文件热替换必然 EPERM。因此导入时切换到「从未打开过」的下一代分区目录
* (aso-browser-g<n>)——零句柄零锁,文件直接拷入即生效,搁置/重启不再存在。
* 3) Cookie 走会话 API:受管浏览器自身登录态先从旧会话导出(cookies.get 返回
* 明文)再写回新分区,源 Cookie 解密后逐条注入。全程不碰分区文件。
* 4) 旧分区目录在下次启动早期清理(那时无人持有句柄,见 cleanupStaleBrowserPartitions)。
*/
/** Chromium 系浏览器的用户数据根目录(按平台) */
const SOURCE_ROOTS: Array<{ id: string; name: string; win: string; mac: string; linux: string }> = [
{
id: 'chrome',
name: 'Google Chrome',
win: join(process.env.LOCALAPPDATA ?? '', 'Google', 'Chrome', 'User Data'),
mac: join(homedir(), 'Library', 'Application Support', 'Google', 'Chrome'),
linux: join(homedir(), '.config', 'google-chrome')
},
{
id: 'edge',
name: 'Microsoft Edge',
win: join(process.env.LOCALAPPDATA ?? '', 'Microsoft', 'Edge', 'User Data'),
mac: join(homedir(), 'Library', 'Application Support', 'Microsoft Edge'),
linux: join(homedir(), '.config', 'microsoft-edge')
},
{
id: 'brave',
name: 'Brave',
win: join(process.env.LOCALAPPDATA ?? '', 'BraveSoftware', 'Brave-Browser', 'User Data'),
mac: join(homedir(), 'Library', 'Application Support', 'BraveSoftware', 'Brave-Browser'),
linux: join(homedir(), '.config', 'BraveSoftware', 'Brave-Browser')
},
{
id: 'chromium',
name: 'Chromium',
win: join(process.env.LOCALAPPDATA ?? '', 'Chromium', 'User Data'),
mac: join(homedir(), 'Library', 'Application Support', 'Chromium'),
linux: join(homedir(), '.config', 'chromium')
}
]
/** 所有受管分区目录的根(userData/Partitions) */
function partitionsRoot(): string {
return join(app.getPath('userData'), 'Partitions')
}
/** 指定分区名的磁盘目录 */
function partitionDirOf(name: string): string {
return join(partitionsRoot(), name)
}
function sourceRoot(id: string): string {
const source = SOURCE_ROOTS.find((s) => s.id === id)
if (!source) return ''
return process.platform === 'win32' ? source.win : process.platform === 'darwin' ? source.mac : source.linux
}
/** 读取 profile 的展示名(Preferences 的 profile.name,失败时用目录名) */
function readProfileName(profileDir: string): string {
try {
const file = join(profileDir, 'Preferences')
if (statSync(file).size > 8 * 1024 * 1024) return profileDir.split(/[\\/]/).pop() || profileDir
const raw = JSON.parse(readFileSync(file, 'utf8')) as { profile?: { name?: string } }
return raw.profile?.name?.trim() || profileDir.split(/[\\/]/).pop() || profileDir
} catch {
return profileDir.split(/[\\/]/).pop() || profileDir
}
}
/** 检测本机可导入的浏览器与 profile 列表 */
export function detectBrowserSources(): BrowserSource[] {
const sources: BrowserSource[] = []
for (const source of SOURCE_ROOTS) {
const root = sourceRoot(source.id)
if (!root || !existsSync(root)) continue
const profiles: BrowserProfileMeta[] = []
try {
for (const entry of readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue
if (entry.name.startsWith('.') || entry.name === 'System Profile' || entry.name === 'Guest Profile') continue
const profileDir = join(root, entry.name)
if (!existsSync(join(profileDir, 'Preferences'))) continue
profiles.push({ dir: entry.name, name: readProfileName(profileDir) })
}
} catch {
// 目录不可读则跳过该浏览器
continue
}
if (profiles.length > 0) {
sources.push({ id: source.id, name: source.name, userDataDir: root, profiles })
}
}
return sources
}
function isLockError(err: unknown): boolean {
const code = (err as NodeJS.ErrnoException).code
return code === 'EBUSY' || code === 'EPERM' || code === 'EACCES'
}
function copyFile(from: string, to: string): void {
copyFileSync(from, to)
}
/** 拷贝 leveldb 目录(跳过 LOCK/LOG 运行时文件),目标已存在时先清空 */
function copyLevelDbDir(from: string, to: string): boolean {
if (!existsSync(from)) return false
const entries = readdirSync(from, { withFileTypes: true })
const files = entries.filter((e) => e.isFile() && e.name !== 'LOCK' && !e.name.startsWith('LOG'))
if (files.length === 0) return false
rmSync(to, { recursive: true, force: true })
mkdirSync(to, { recursive: true })
for (const file of files) {
copyFile(join(from, file.name), join(to, file.name))
}
return true
}
interface SourceKeyInfo {
key: string | null
/** 不可解密场景(非 Windows / app-bound V20 / 缺失)的说明 */
note?: string
}
/**
* 读取源浏览器 Local State 里的 os_crypt.encrypted_key 并判断可用性。
* Windows:DPAPI 前缀可解(PowerShell CryptUnprotectData,用户级);
* V20 为 app-bound 加密(Chrome/Edge 127+),第三方无法解密,明确降级。
* macOS/Linux:密钥在系统钥匙串/KWallet,仅迁移 Local Storage。
*/
function readSourceKey(userDataDir: string): SourceKeyInfo {
if (process.platform !== 'win32') {
return { key: null, note: '当前平台的浏览器 Cookie 密钥绑定系统钥匙串,暂不支持迁移 Cookie(LocalStorage 登录态仍会导入)。' }
}
try {
const raw = JSON.parse(readFileSync(join(userDataDir, 'Local State'), 'utf8')) as {
os_crypt?: { encrypted_key?: string }
}
const encoded = raw.os_crypt?.encrypted_key
if (!encoded) return { key: null, note: '未找到源浏览器 Cookie 密钥,跳过 Cookie 迁移。' }
const bytes = Buffer.from(encoded, 'base64')
if (bytes.subarray(0, 3).toString('utf8') === 'V20') {
return { key: null, note: '该浏览器启用了应用绑定加密(app-bound),Cookie 无法第三方迁移;LocalStorage 已导入,需登录的站点在受管浏览器里登录一次即可。' }
}
if (bytes.subarray(0, 5).toString('utf8') !== 'DPAPI') {
return { key: null, note: '源浏览器 Cookie 密钥格式不受支持,跳过 Cookie 迁移。' }
}
return { key: encoded }
} catch {
return { key: null, note: '读取源浏览器 Local State 失败,跳过 Cookie 迁移。' }
}
}
/** DPAPI 解出源浏览器的 AES 密钥(PowerShell 调 CryptUnprotectData;用户级,无需管理员)。
* Chromium 的 encrypted_key = base64("DPAPI" + 密文 blob):CryptUnprotectData 只接受
* 剥掉 5 字节前缀后的 blob,前缀判断必须在解码后的字节上做——base64 文本本身
* 永远不以 "DPAPI" 开头(实测带前缀调用必然 CryptographicException: 数据无效)。 */
function dpapiUnprotectKey(encodedKey: string): Promise<Buffer | null> {
const bytes = Buffer.from(encodedKey, 'base64')
const blob = bytes.subarray(0, 5).toString('utf8') === 'DPAPI' ? bytes.subarray(5).toString('base64') : encodedKey
const script =
`$d=[Convert]::FromBase64String('${blob}');` +
'Add-Type -AssemblyName System.Security;' +
'[Convert]::ToBase64String([System.Security.Cryptography.ProtectedData]::Unprotect($d,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser))'
return new Promise((resolve) => {
execFile(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-Command', script],
{ timeout: 20_000 },
(err, stdout, stderr) => {
if (err || !stdout.trim()) {
log.warn('dpapi unprotect failed', {
err: err instanceof Error ? err.message : String(err ?? 'empty output'),
stderr: stderr ? String(stderr).slice(0, 200) : undefined
})
resolve(null)
return
}
try {
resolve(Buffer.from(stdout.trim(), 'base64'))
} catch {
resolve(null)
}
}
)
})
}
/** 解密单条 Chrome Cookie(v10/v11 均为 AES-256-GCM:3 字节版本 + 12 字节 nonce + 密文 + 16 字节 tag) */
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<SourceCookieRead> {
if (process.platform !== 'win32') {
return { kind: 'unsupported', note: '当前平台的浏览器 Cookie 密钥绑定系统钥匙串,暂不支持迁移 Cookie(LocalStorage 登录态仍会导入)。' }
}
const keyInfo = readSourceKey(root)
if (!keyInfo.key) return { kind: 'unsupported', note: keyInfo.note ?? '无可用 Cookie 密钥,跳过 Cookie 迁移。' }
const dbFile = join(staging, 'Network', 'Cookies')
if (!existsSync(dbFile)) return { kind: 'unsupported', note: '源浏览器没有可迁移的 Cookie 数据库。' }
const key = await dpapiUnprotectKey(keyInfo.key)
if (!key) {
return { kind: 'unsupported', note: 'Cookie 密钥解密失败(DPAPI),本次未导入 Cookie,可稍后重试。' }
}
const db = new DatabaseSync(dbFile, { readOnly: true })
try {
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<BrowserImportResult> {
if (importInFlight) throw new Error('已有导入正在进行,请稍候。')
importInFlight = true
try {
return await importBrowserProfileInner(sourceId, profileDir)
} finally {
importInFlight = false
}
}
async function importBrowserProfileInner(sourceId: string, profileDir: string): Promise<BrowserImportResult> {
const browser = getBrowserController()
const root = sourceRoot(sourceId)
if (!root || !existsSync(root)) throw new Error('未找到该浏览器的用户数据目录。')
// 只接受该根目录下的直接子目录名,拒绝任意路径注入
const dirName = profileDir.split(/[\\/]/).pop() ?? ''
const sourceProfile = join(root, dirName)
if (dirName !== profileDir || !existsSync(join(sourceProfile, 'Preferences'))) {
throw new Error('profile 目录不存在。')
}
const sourceName = SOURCE_ROOTS.find((s) => s.id === sourceId)?.name ?? sourceId
const profileName = readProfileName(sourceProfile)
// 1) collect + 源 Cookie 解密(源数据全部先抓到手,之后源浏览器锁不锁都无所谓)
const staging = join(partitionsRoot(), `.import-staging-${Date.now()}`)
let collected: CollectedImport
try {
mkdirSync(staging, { recursive: true })
collected = collectFromSource(root, sourceProfile, staging)
} catch (err) {
rmSync(staging, { recursive: true, force: true })
throw err
}
const sourceRead: SourceCookieRead = collected.cookieNote
? { kind: 'unsupported', note: collected.cookieNote }
: await readSourceCookies(root, staging)
// 2) 受管浏览器自身的登录态快照(随分区轮换带过去;会话 API 返回明文)
const carried = await browser.exportCookies()
// 3) 关闭标签(导入完成后按 URL 恢复)
const urls = browser.closeForImport()
// 4) 分区轮换 + Local Storage 文件迁移:当前分区已有 Local Storage 落盘数据
// 说明其句柄可能被本应用 Chromium 持有,直接轮换到全新目录(零句柄零锁)
if (existsSync(join(partitionDirOf(browser.partitionName()), 'Local Storage'))) {
browser.rotatePartition()
}
try {
applyStagedLocalStorage(staging, partitionDirOf(browser.partitionName()))
} catch (err) {
browser.restoreTabs(urls)
rmSync(staging, { recursive: true, force: true })
throw new Error(`导入写入失败: ${err instanceof Error ? err.message : String(err)}`)
}
// 5) Cookie:先回写受管浏览器原有登录态,再注入源 Cookie(全程会话 API,不碰文件)
const carry = await browser.importCookies(carried)
let injected = 0
let cookieNote: string | undefined
if (sourceRead.kind === 'ok') {
const result = await browser.importCookies(sourceRead.cookies)
injected = result.imported
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<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 })
}