feat(browser): 导入热应用——Cookie 解密注入 + 内核热重启,不再需要重启应用
This commit is contained in:
@@ -392,6 +392,34 @@ export class BrowserController {
|
|||||||
return this.settings.imported ?? null
|
return this.settings.imported ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 内核级热重启(导入热应用用):销毁全部标签、清空分区存储后端
|
||||||
|
* (Chromium 释放 leveldb/cookie 数据句柄),返回被销毁标签的 URL 供恢复 */
|
||||||
|
async restartKernel(): Promise<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 {
|
||||||
|
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<void> {
|
||||||
|
await this.browserSession.cookies.set(details)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 代理设置 ----
|
// ---- 代理设置 ----
|
||||||
|
|
||||||
private proxySettings(): BrowserProxySettings {
|
private proxySettings(): BrowserProxySettings {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
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 { homedir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import type { BrowserImportInfo, BrowserImportResult, BrowserProfileMeta, BrowserSource } from '@shared/types'
|
import type { BrowserImportInfo, BrowserImportResult, BrowserProfileMeta, BrowserSource } from '@shared/types'
|
||||||
@@ -11,21 +14,18 @@ const log = getLogger('browser-profile')
|
|||||||
/**
|
/**
|
||||||
* 本地浏览器配置导入(复用各网站登录态)。
|
* 本地浏览器配置导入(复用各网站登录态)。
|
||||||
*
|
*
|
||||||
* 实现思路(零解密依赖,纯文件迁移):
|
* 迁移策略(collect → apply 两段式):
|
||||||
* - Local Storage / IndexedDB 在 Chromium 里是明文 leveldb,直接拷入受管分区即可生效。
|
* 1) collect:源 profile 的 Local Storage(明文 leveldb)与 Cookies 数据库先拷入
|
||||||
* - Cookies 用 AES-GCM 加密,密钥经 OS 加密后存于浏览器根目录 Local State
|
* 暂存目录——此刻把数据抓到手,源浏览器之后锁不锁都无所谓;
|
||||||
* (os_crypt.encrypted_key)。Windows 上该密钥由 DPAPI(用户级)包裹——同一
|
* Windows 上同时把源 os_crypt 密钥(DPAPI 包裹)移植进本应用 Local State。
|
||||||
* Windows 用户的任意进程都能解开,因此把这份密钥「移植」进本应用的 Local State,
|
* 2) apply 热路径(无需重启应用):
|
||||||
* 本应用内的 Chromium 即可解密拷贝过来的 Cookies(Chrome 127+ 的 app-bound
|
* - Local Storage:文件迁移;分区被本应用 Chromium 锁住时执行「内核热重启」
|
||||||
* 加密 V20 除外,会明确提示不支持)。macOS/Linux 的密钥绑定钥匙串/KWallet,
|
* (销毁全部标签 + clearStorageData 释放存储句柄)后重试,随后自动恢复标签;
|
||||||
* Cookies 无法直接迁移,仅迁移 Local Storage。
|
* - Cookies:用 DPAPI 解出的密钥自行解密源 Cookies 数据库,逐条经
|
||||||
*
|
* session.cookies.set 热注入(文件替换在进程存活期间永远不可行——
|
||||||
* 两段式迁移(collect → apply):
|
* CookieManager 与内存 CookieJar 都会覆盖它)。
|
||||||
* 1) collect:源 profile → 暂存目录(此刻把数据抓到手,源浏览器之后锁不锁都无所谓);
|
* 3) 兜底:Cookie 无法解密(非 Windows / app-bound V20 / DPAPI 失败)时,
|
||||||
* 2) apply:暂存目录 → 受管分区。分区数据可能被本应用自己的 Chromium 锁住
|
* Cookies 文件转待办任务(pending),下次启动早期自动落盘生效。
|
||||||
* (本次运行打开过网页,leveldb 句柄即使关掉标签也不释放),此时不硬刚——
|
|
||||||
* 暂存目录转正为待办任务(pending),applyPendingProfileImport 在下次启动
|
|
||||||
* 早期(分区尚未被 Chromium 打开)自动完成迁移。
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Chromium 系浏览器的用户数据根目录(按平台) */
|
/** Chromium 系浏览器的用户数据根目录(按平台) */
|
||||||
@@ -147,8 +147,9 @@ interface SourceKeyInfo {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 读取源浏览器 Local State 里的 os_crypt.encrypted_key 并判断可移植性。
|
* 读取源浏览器 Local State 里的 os_crypt.encrypted_key 并判断可移植性。
|
||||||
* Windows:前缀 DPAPI 可移植;V20 为 app-bound 加密(Chrome/Edge 127+),
|
* Windows:前缀 DPAPI 可移植(文件兜底路径靠移植它;注入路径用它解密);
|
||||||
* 其他进程无法解开,明确降级。macOS/Linux:密钥在系统钥匙串/KWallet,跳过。
|
* V20 为 app-bound 加密(Chrome/Edge 127+),明确降级。
|
||||||
|
* macOS/Linux:密钥在系统钥匙串/KWallet,Cookies 无法解密,仅迁移 Local Storage。
|
||||||
*/
|
*/
|
||||||
function readSourceKey(userDataDir: string): SourceKeyInfo {
|
function readSourceKey(userDataDir: string): SourceKeyInfo {
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== 'win32') {
|
||||||
@@ -173,7 +174,47 @@ function readSourceKey(userDataDir: string): SourceKeyInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 把源密钥移植进本应用根 Local State(首次会备份原文件) */
|
/** DPAPI 解出源浏览器的 AES 密钥(PowerShell 调 CryptUnprotectData;用户级,无需管理员) */
|
||||||
|
function dpapiUnprotectKey(encodedKey: string): Promise<Buffer | null> {
|
||||||
|
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 {
|
function transplantKey(sourceKey: string): void {
|
||||||
const appLocalState = join(app.getPath('userData'), 'Local State')
|
const appLocalState = join(app.getPath('userData'), 'Local State')
|
||||||
const backup = `${appLocalState}.aso-backup`
|
const backup = `${appLocalState}.aso-backup`
|
||||||
@@ -196,6 +237,8 @@ interface CollectedImport {
|
|||||||
cookies: boolean
|
cookies: boolean
|
||||||
localStorage: boolean
|
localStorage: boolean
|
||||||
cookieNote?: string
|
cookieNote?: string
|
||||||
|
/** 已移植源密钥(Cookie 文件兜底路径在重启后由本应用 Chromium 解密需要它) */
|
||||||
|
transplanted: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -215,6 +258,7 @@ function collectFromSource(root: string, sourceProfile: string, staging: string)
|
|||||||
|
|
||||||
let cookies = false
|
let cookies = false
|
||||||
let cookieNote: string | undefined
|
let cookieNote: string | undefined
|
||||||
|
let transplanted = false
|
||||||
const keyInfo = readSourceKey(root)
|
const keyInfo = readSourceKey(root)
|
||||||
const cookieDb = existsSync(join(sourceProfile, 'Network', 'Cookies'))
|
const cookieDb = existsSync(join(sourceProfile, 'Network', 'Cookies'))
|
||||||
? join(sourceProfile, 'Network', 'Cookies')
|
? join(sourceProfile, 'Network', 'Cookies')
|
||||||
@@ -224,6 +268,7 @@ function collectFromSource(root: string, sourceProfile: string, staging: string)
|
|||||||
if (keyInfo.key && cookieDb) {
|
if (keyInfo.key && cookieDb) {
|
||||||
try {
|
try {
|
||||||
transplantKey(keyInfo.key)
|
transplantKey(keyInfo.key)
|
||||||
|
transplanted = true
|
||||||
mkdirSync(join(staging, 'Network'), { recursive: true })
|
mkdirSync(join(staging, 'Network'), { recursive: true })
|
||||||
copyFile(cookieDb, join(staging, 'Network', 'Cookies'))
|
copyFile(cookieDb, join(staging, 'Network', 'Cookies'))
|
||||||
for (const suffix of ['-wal', '-shm']) {
|
for (const suffix of ['-wal', '-shm']) {
|
||||||
@@ -241,26 +286,99 @@ function collectFromSource(root: string, sourceProfile: string, staging: string)
|
|||||||
cookieNote = keyInfo.note ?? '源浏览器没有可迁移的 Cookie 数据库。'
|
cookieNote = keyInfo.note ?? '源浏览器没有可迁移的 Cookie 数据库。'
|
||||||
}
|
}
|
||||||
|
|
||||||
return { cookies, localStorage, cookieNote }
|
return { cookies, localStorage, cookieNote, transplanted }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** apply:暂存目录 → 受管分区(分区被本应用 Chromium 锁住时抛原始 lock 错误) */
|
/** apply(LocalStorage):暂存 → 受管分区(分区被本应用 Chromium 锁住时抛原始 lock 错误) */
|
||||||
function applyStagedToPartition(staging: string): void {
|
function applyStagedLocalStorage(staging: string): void {
|
||||||
const target = partitionDir()
|
copyLevelDbDir(join(staging, 'Local Storage', 'leveldb'), join(partitionDir(), 'Local Storage', 'leveldb'))
|
||||||
copyLevelDbDir(join(staging, 'Local Storage', 'leveldb'), join(target, 'Local Storage', 'leveldb'))
|
}
|
||||||
|
|
||||||
|
/** apply(Cookies 文件兜底):仅密钥已移植时有效(重启后 Chromium 用移植密钥解密) */
|
||||||
|
function applyStagedCookies(staging: string): void {
|
||||||
const cookieDb = join(staging, 'Network', 'Cookies')
|
const cookieDb = join(staging, 'Network', 'Cookies')
|
||||||
if (existsSync(cookieDb)) {
|
if (!existsSync(cookieDb)) return
|
||||||
|
const target = partitionDir()
|
||||||
mkdirSync(join(target, 'Network'), { recursive: true })
|
mkdirSync(join(target, 'Network'), { recursive: true })
|
||||||
copyFile(cookieDb, join(target, 'Network', 'Cookies'))
|
copyFile(cookieDb, join(target, 'Network', 'Cookies'))
|
||||||
for (const suffix of ['-wal', '-shm']) {
|
for (const suffix of ['-wal', '-shm']) {
|
||||||
if (existsSync(`${cookieDb}${suffix}`)) copyFile(`${cookieDb}${suffix}`, join(target, 'Network', `Cookies${suffix}`))
|
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<CookieInjectResult> {
|
||||||
|
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 {
|
export function applyPendingProfileImport(): void {
|
||||||
const pending = pendingDir()
|
const pending = pendingDir()
|
||||||
@@ -268,7 +386,8 @@ export function applyPendingProfileImport(): void {
|
|||||||
if (!existsSync(metaFile)) return
|
if (!existsSync(metaFile)) return
|
||||||
try {
|
try {
|
||||||
const meta = JSON.parse(readFileSync(metaFile, 'utf8')) as BrowserImportInfo
|
const meta = JSON.parse(readFileSync(metaFile, 'utf8')) as BrowserImportInfo
|
||||||
applyStagedToPartition(pending)
|
applyStagedLocalStorage(pending)
|
||||||
|
applyStagedCookies(pending)
|
||||||
rmSync(pending, { recursive: true, force: true })
|
rmSync(pending, { recursive: true, force: true })
|
||||||
getBrowserController().setImportedInfo({ ...meta, note: undefined })
|
getBrowserController().setImportedInfo({ ...meta, note: undefined })
|
||||||
log.info('pending profile import applied', { browser: meta.browser, profile: meta.profile })
|
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<BrowserImportResult> {
|
export async function importBrowserProfile(sourceId: string, profileDir: string): Promise<BrowserImportResult> {
|
||||||
const browser = getBrowserController()
|
const browser = getBrowserController()
|
||||||
if (browser.hasTabs) {
|
|
||||||
throw new Error('请先关闭内置浏览器的所有标签页,再导入浏览器数据。')
|
|
||||||
}
|
|
||||||
const root = sourceRoot(sourceId)
|
const root = sourceRoot(sourceId)
|
||||||
if (!root || !existsSync(root)) throw new Error('未找到该浏览器的用户数据目录。')
|
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 sourceName = SOURCE_ROOTS.find((s) => s.id === sourceId)?.name ?? sourceId
|
||||||
const profileName = readProfileName(sourceProfile)
|
const profileName = readProfileName(sourceProfile)
|
||||||
|
|
||||||
// 1) collect:源 → 暂存目录(源数据先抓到手)
|
// 1) collect:源 → 暂存目录(源数据先抓到手;Cookies 文件作为解密源与兜底)
|
||||||
const staging = join(app.getPath('userData'), 'Partitions', `.import-staging-${Date.now()}`)
|
const staging = join(app.getPath('userData'), 'Partitions', `.import-staging-${Date.now()}`)
|
||||||
let collected: CollectedImport
|
let collected: CollectedImport
|
||||||
try {
|
try {
|
||||||
@@ -311,64 +427,144 @@ export async function importBrowserProfile(sourceId: string, profileDir: string)
|
|||||||
rmSync(staging, { recursive: true, force: true })
|
rmSync(staging, { recursive: true, force: true })
|
||||||
throw err
|
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 {
|
try {
|
||||||
applyStagedToPartition(staging)
|
applyStagedLocalStorage(staging)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isLockError(err)) {
|
if (!isLockError(err)) {
|
||||||
rmSync(staging, { recursive: true, force: true })
|
rmSync(staging, { recursive: true, force: true })
|
||||||
throw new Error(`导入写入失败: ${err instanceof Error ? err.message : String(err)}`)
|
throw new Error(`导入写入失败: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
}
|
}
|
||||||
// 占用错误:暂存目录保留,写 meta 后整体 rename 到 pending
|
const urls = await browser.restartKernel()
|
||||||
const deferredNote = '检测到受管浏览器数据正被本应用占用,已计划在重启应用后自动完成导入。'
|
restarted = true
|
||||||
|
try {
|
||||||
|
applyStagedLocalStorage(staging)
|
||||||
|
} catch (err2) {
|
||||||
|
// 理论不可达(clearStorageData 已释放句柄);兜底:整体转待办,重启应用后完成
|
||||||
|
const deferredNote = '受管浏览器数据正被占用,已计划在重启应用后自动完成导入。'
|
||||||
mkdirSync(join(app.getPath('userData'), 'Partitions'), { recursive: true })
|
mkdirSync(join(app.getPath('userData'), 'Partitions'), { recursive: true })
|
||||||
rmSync(pendingDir(), { recursive: true, force: true })
|
rmSync(pendingDir(), { recursive: true, force: true })
|
||||||
writeFileSync(join(staging, IMPORT_META_FILE), JSON.stringify({ ...info, note: deferredNote }, null, 2), 'utf8')
|
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())
|
renameSync(staging, pendingDir())
|
||||||
browser.setImportedInfo({ ...info, note: deferredNote })
|
browser.setImportedInfo({
|
||||||
log.warn('partition locked, import deferred to next launch', { source: sourceId, profile: dirName })
|
browser: sourceName,
|
||||||
return {
|
profile: profileName,
|
||||||
ok: true,
|
at: new Date().toISOString(),
|
||||||
deferred: true,
|
|
||||||
cookies: collected.cookies,
|
cookies: collected.cookies,
|
||||||
localStorage: collected.localStorage,
|
localStorage: true,
|
||||||
cookieNote: deferredNote,
|
note: deferredNote
|
||||||
message: 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 })
|
rmSync(staging, { recursive: true, force: true })
|
||||||
|
if (restarted) browser.restoreTabs([])
|
||||||
const parts: string[] = []
|
browser.setImportedInfo({
|
||||||
if (collected.cookies) parts.push('Cookie(站点登录态)')
|
browser: sourceName,
|
||||||
if (collected.localStorage) parts.push('LocalStorage')
|
profile: profileName,
|
||||||
if (parts.length === 0) {
|
at: new Date().toISOString(),
|
||||||
return {
|
cookies: true,
|
||||||
ok: false,
|
localStorage: collected.localStorage,
|
||||||
cookies: false,
|
note: collected.cookieNote
|
||||||
localStorage: false,
|
})
|
||||||
cookieNote: collected.cookieNote,
|
|
||||||
message: `未在 ${sourceName}(${dirName})中找到可迁移的登录数据。`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
browser.setImportedInfo(info)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
cookies: collected.cookies,
|
cookies: true,
|
||||||
localStorage: collected.localStorage,
|
localStorage: collected.localStorage,
|
||||||
cookieNote: collected.cookieNote,
|
cookieNote: collected.cookieNote,
|
||||||
message: `已从 ${sourceName} 导入 ${parts.join(' + ')}。${collected.cookieNote ?? ''}${
|
message: `已从 ${sourceName} 导入 Cookie + LocalStorage${restarted ? '(浏览器内核已热重启)' : ''}。若登录态未生效,重启应用后重试。`
|
||||||
collected.cookies ? '若登录态未生效,重启应用后重试。' : ''
|
}
|
||||||
}`
|
}
|
||||||
|
|
||||||
|
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: false,
|
||||||
|
localStorage: true,
|
||||||
|
cookieNote: inject.note,
|
||||||
|
message: `已从 ${sourceName} 导入 LocalStorage。${inject.note ?? ''}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user