diff --git a/apps/desktop/src/main/browser/browser-controller.ts b/apps/desktop/src/main/browser/browser-controller.ts index 2770515..253c1dd 100644 --- a/apps/desktop/src/main/browser/browser-controller.ts +++ b/apps/desktop/src/main/browser/browser-controller.ts @@ -392,6 +392,34 @@ export class BrowserController { return this.settings.imported ?? null } + /** 内核级热重启(导入热应用用):销毁全部标签、清空分区存储后端 + * (Chromium 释放 leveldb/cookie 数据句柄),返回被销毁标签的 URL 供恢复 */ + async restartKernel(): Promise { + 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 { + this.newTab(url, false) + } catch (err) { + log.warn('restore tab failed', { url, err: err instanceof Error ? err.message : String(err) }) + } + } + } + + /** 热注入单条 Cookie(导入路径:源数据经解密后逐条写入分区会话) */ + async setCookie(details: Electron.CookiesSetDetails): Promise { + await this.browserSession.cookies.set(details) + } + // ---- 代理设置 ---- private proxySettings(): BrowserProxySettings { diff --git a/apps/desktop/src/main/browser/browser-profile.ts b/apps/desktop/src/main/browser/browser-profile.ts index a321705..675abef 100644 --- a/apps/desktop/src/main/browser/browser-profile.ts +++ b/apps/desktop/src/main/browser/browser-profile.ts @@ -1,5 +1,8 @@ import { app } from 'electron' import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs' +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' @@ -11,21 +14,18 @@ const log = getLogger('browser-profile') /** * 本地浏览器配置导入(复用各网站登录态)。 * - * 实现思路(零解密依赖,纯文件迁移): - * - 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。 - * - * 两段式迁移(collect → apply): - * 1) collect:源 profile → 暂存目录(此刻把数据抓到手,源浏览器之后锁不锁都无所谓); - * 2) apply:暂存目录 → 受管分区。分区数据可能被本应用自己的 Chromium 锁住 - * (本次运行打开过网页,leveldb 句柄即使关掉标签也不释放),此时不硬刚—— - * 暂存目录转正为待办任务(pending),applyPendingProfileImport 在下次启动 - * 早期(分区尚未被 Chromium 打开)自动完成迁移。 + * 迁移策略(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),下次启动早期自动落盘生效。 */ /** Chromium 系浏览器的用户数据根目录(按平台) */ @@ -147,8 +147,9 @@ interface SourceKeyInfo { /** * 读取源浏览器 Local State 里的 os_crypt.encrypted_key 并判断可移植性。 - * Windows:前缀 DPAPI 可移植;V20 为 app-bound 加密(Chrome/Edge 127+), - * 其他进程无法解开,明确降级。macOS/Linux:密钥在系统钥匙串/KWallet,跳过。 + * Windows:前缀 DPAPI 可移植(文件兜底路径靠移植它;注入路径用它解密); + * V20 为 app-bound 加密(Chrome/Edge 127+),明确降级。 + * macOS/Linux:密钥在系统钥匙串/KWallet,Cookies 无法解密,仅迁移 Local Storage。 */ function readSourceKey(userDataDir: string): SourceKeyInfo { if (process.platform !== 'win32') { @@ -173,7 +174,47 @@ function readSourceKey(userDataDir: string): SourceKeyInfo { } } -/** 把源密钥移植进本应用根 Local State(首次会备份原文件) */ +/** DPAPI 解出源浏览器的 AES 密钥(PowerShell 调 CryptUnprotectData;用户级,无需管理员) */ +function dpapiUnprotectKey(encodedKey: string): Promise { + const blob = encodedKey.startsWith('DPAPI') ? encodedKey.slice(5) : 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) => { + if (err || !stdout.trim()) { + log.warn('dpapi unprotect failed', { err: err instanceof Error ? err.message : String(err ?? 'empty output') }) + 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') +} + +/** 把源密钥移植进本应用根 Local State(首次会备份原文件;Cookie 文件兜底路径需要它) */ function transplantKey(sourceKey: string): void { const appLocalState = join(app.getPath('userData'), 'Local State') const backup = `${appLocalState}.aso-backup` @@ -196,6 +237,8 @@ interface CollectedImport { cookies: boolean localStorage: boolean cookieNote?: string + /** 已移植源密钥(Cookie 文件兜底路径在重启后由本应用 Chromium 解密需要它) */ + transplanted: boolean } /** @@ -215,6 +258,7 @@ 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') @@ -224,6 +268,7 @@ function collectFromSource(root: string, sourceProfile: string, staging: string) 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']) { @@ -241,26 +286,99 @@ function collectFromSource(root: string, sourceProfile: string, staging: string) cookieNote = keyInfo.note ?? '源浏览器没有可迁移的 Cookie 数据库。' } - return { cookies, localStorage, cookieNote } + return { cookies, localStorage, cookieNote, transplanted } } -/** apply:暂存目录 → 受管分区(分区被本应用 Chromium 锁住时抛原始 lock 错误) */ -function applyStagedToPartition(staging: string): void { - const target = partitionDir() - copyLevelDbDir(join(staging, 'Local Storage', 'leveldb'), join(target, 'Local Storage', 'leveldb')) +/** apply(LocalStorage):暂存 → 受管分区(分区被本应用 Chromium 锁住时抛原始 lock 错误) */ +function applyStagedLocalStorage(staging: string): void { + copyLevelDbDir(join(staging, 'Local Storage', 'leveldb'), join(partitionDir(), 'Local Storage', 'leveldb')) +} + +/** apply(Cookies 文件兜底):仅密钥已移植时有效(重启后 Chromium 用移植密钥解密) */ +function applyStagedCookies(staging: string): void { const cookieDb = join(staging, 'Network', 'Cookies') - if (existsSync(cookieDb)) { - 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}`)) + 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 } + | { kind: 'unsupported'; note: string } + +/** + * Cookie 热注入:解密暂存 Cookies 数据库并逐条写入分区会话(不碰文件,无需重启)。 + * 返回 unsupported 表示当前环境无法解密(非 Windows / 无密钥 / DPAPI 失败)。 + */ +async function injectCookiesFromStaging(root: string, staging: string): Promise { + if (process.platform !== 'win32') { + return { kind: 'unsupported', note: '当前平台 Cookie 密钥绑定系统钥匙串,Cookies 将以文件方式在重启后自动落盘。' } + } + const keyInfo = readSourceKey(root) + if (!keyInfo.key) return { kind: 'unsupported', note: keyInfo.note ?? '无可用 Cookie 密钥,跳过 Cookie 热注入。' } + const key = await dpapiUnprotectKey(keyInfo.key) + if (!key) { + return { kind: 'unsupported', note: 'Cookie 密钥解密失败(DPAPI),Cookies 将以文件方式在重启后自动落盘。' } + } + + const db = new DatabaseSync(join(staging, 'Network', 'Cookies'), { readOnly: true }) + try { + const rows = db + .prepare('SELECT host_key, name, value, encrypted_value, path, is_secure, is_httponly, expires_utc FROM cookies') + .all() + const controller = getBrowserController() + let injected = 0 + let failed = 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 + } + let value = r.value + if (!value && r.encrypted_value && r.encrypted_value.length > 3) { + value = decryptChromeBlob(r.encrypted_value, key) + } + if (!value) { + failed++ + continue + } + const secure = r.is_secure === 1 + const details: Electron.CookiesSetDetails = { + url: `${secure ? 'https' : 'http'}://${r.host_key.replace(/^\./, '')}${r.path || '/'}`, + name: r.name, + value, + domain: r.host_key, + 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++ + } catch { + failed++ + } } + return { kind: 'ok', injected, failed } + } finally { + db.close() } } /** - * 应用启动早期调用(分区尚未被 Chromium 打开):完成上次因分区被占用 - * 而搁置的待办导入。失败则保留待办,下次启动重试。 + * 应用启动早期调用(分区尚未被 Chromium 打开):完成上次 Cookie 文件兜底 + * 路径搁置的待办导入。失败则保留待办,下次启动重试。 */ export function applyPendingProfileImport(): void { const pending = pendingDir() @@ -268,7 +386,8 @@ export function applyPendingProfileImport(): void { if (!existsSync(metaFile)) return try { const meta = JSON.parse(readFileSync(metaFile, 'utf8')) as BrowserImportInfo - applyStagedToPartition(pending) + 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 }) @@ -284,12 +403,9 @@ export function applyPendingProfileImport(): void { } } -/** 导入指定浏览器的 profile 配置到受管浏览器分区 */ +/** 导入指定浏览器的 profile 配置到受管浏览器分区(热应用:通常无需重启应用) */ export async function importBrowserProfile(sourceId: string, profileDir: string): Promise { const browser = getBrowserController() - if (browser.hasTabs) { - throw new Error('请先关闭内置浏览器的所有标签页,再导入浏览器数据。') - } const root = sourceRoot(sourceId) if (!root || !existsSync(root)) throw new Error('未找到该浏览器的用户数据目录。') // 只接受该根目录下的直接子目录名,拒绝任意路径注入 @@ -301,7 +417,7 @@ 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:源 → 暂存目录(源数据先抓到手) + // 1) collect:源 → 暂存目录(源数据先抓到手;Cookies 文件作为解密源与兜底) const staging = join(app.getPath('userData'), 'Partitions', `.import-staging-${Date.now()}`) let collected: CollectedImport try { @@ -311,64 +427,144 @@ export async function importBrowserProfile(sourceId: string, profileDir: string) rmSync(staging, { recursive: true, force: true }) throw err } - const info: BrowserImportInfo = { - browser: sourceName, - profile: profileName, - at: new Date().toISOString(), - cookies: collected.cookies, - localStorage: collected.localStorage, - note: collected.cookieNote - } - // 2) apply:暂存 → 受管分区;分区被本应用 Chromium 占用时转待办(重启后自动完成) + // 2) Local Storage 热应用:被分区占用时执行内核热重启(销毁标签 + 释放存储句柄) + let restarted = false try { - applyStagedToPartition(staging) + applyStagedLocalStorage(staging) } catch (err) { if (!isLockError(err)) { rmSync(staging, { recursive: true, force: true }) throw new Error(`导入写入失败: ${err instanceof Error ? err.message : String(err)}`) } - // 占用错误:暂存目录保留,写 meta 后整体 rename 到 pending - const deferredNote = '检测到受管浏览器数据正被本应用占用,已计划在重启应用后自动完成导入。' - mkdirSync(join(app.getPath('userData'), 'Partitions'), { recursive: true }) - rmSync(pendingDir(), { recursive: true, force: true }) - writeFileSync(join(staging, IMPORT_META_FILE), JSON.stringify({ ...info, note: deferredNote }, null, 2), 'utf8') - renameSync(staging, pendingDir()) - browser.setImportedInfo({ ...info, note: deferredNote }) - log.warn('partition locked, import deferred to next launch', { source: sourceId, profile: dirName }) + 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 } + } + 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, - deferred: true, - cookies: collected.cookies, + cookies: true, localStorage: collected.localStorage, - cookieNote: deferredNote, - message: deferredNote - } - } - rmSync(staging, { recursive: true, force: true }) - - const parts: string[] = [] - if (collected.cookies) parts.push('Cookie(站点登录态)') - if (collected.localStorage) parts.push('LocalStorage') - if (parts.length === 0) { - return { - ok: false, - cookies: false, - localStorage: false, cookieNote: collected.cookieNote, - message: `未在 ${sourceName}(${dirName})中找到可迁移的登录数据。` + message: `已从 ${sourceName} 导入 Cookie + LocalStorage${restarted ? '(浏览器内核已热重启)' : ''}。若登录态未生效,重启应用后重试。` } } - browser.setImportedInfo(info) + 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({ + browser: sourceName, + profile: profileName, + at: new Date().toISOString(), + cookies: false, + localStorage: true, + note: inject.note + }) return { ok: true, - cookies: collected.cookies, - localStorage: collected.localStorage, - cookieNote: collected.cookieNote, - message: `已从 ${sourceName} 导入 ${parts.join(' + ')}。${collected.cookieNote ?? ''}${ - collected.cookies ? '若登录态未生效,重启应用后重试。' : '' - }` + cookies: false, + localStorage: true, + cookieNote: inject.note, + message: `已从 ${sourceName} 导入 LocalStorage。${inject.note ?? ''}` } }