fix(browser): 导入彻底无感——分区轮换替代文件热替换,搁置/重启路径全部删除

This commit is contained in:
Rick Ye
2026-09-13 00:34:50 +08:00
parent 95044c3a15
commit 06a316b6d1
7 changed files with 331 additions and 331 deletions
+157 -247
View File
@@ -1,31 +1,30 @@
import { app } from 'electron'
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
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 } from './browser-controller'
import { getBrowserController, type BrowserCookieSeed } from './browser-controller'
import { getLogger } from '../logger'
const log = getLogger('browser-profile')
/**
* 本地浏览器配置导入(复用各网站登录态)。
* 本地浏览器配置导入(复用各网站登录态)——全程无感,无需重启应用:
*
* 迁移策略(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),下次启动早期自动落盘生效。
* 暂存目录,源数据全部抓到手;Windows 上用 DPAPI 解出源 Cookie 密钥,
* 自行解密暂存 Cookies 库得到明文 Cookie 列表。
* 2) 分区轮换:受管分区一旦被本应用 Chromium 打开过,其 leveldb / Cookies 文件
* 句柄在本进程内无法释放(clearStorageData 只清数据不关句柄),对存活分区
* 做文件热替换必然 EPERM。因此导入时切换到「从未打开过」的下一代分区目录
* (aso-browser-g<n>)——零句柄零锁,文件直接拷入即生效,搁置/重启不再存在。
* 3) Cookie 走会话 API:受管浏览器自身登录态先从旧会话导出(cookies.get 返回
* 明文)再写回新分区,源 Cookie 解密后逐条注入。全程不碰分区文件。
* 4) 旧分区目录在下次启动早期清理(那时无人持有句柄,见 cleanupStaleBrowserPartitions)。
*/
/** Chromium 系浏览器的用户数据根目录(按平台) */
@@ -60,18 +59,16 @@ const SOURCE_ROOTS: Array<{ id: string; name: string; win: string; mac: string;
}
]
/** 受管浏览器分区目录(persist:aso-browser 的落盘位置) */
function partitionDir(): string {
return join(app.getPath('userData'), 'Partitions', 'aso-browser')
/** 所有受管分区目录的根(userData/Partitions) */
function partitionsRoot(): string {
return join(app.getPath('userData'), 'Partitions')
}
/** 待办导入目录(分区被占用时的暂存转正;启动早期自动应用) */
function pendingDir(): string {
return join(app.getPath('userData'), 'Partitions', 'aso-browser.pending-import')
/** 指定分区名的磁盘目录 */
function partitionDirOf(name: string): string {
return join(partitionsRoot(), name)
}
const IMPORT_META_FILE = 'import-meta.json'
function sourceRoot(id: string): string {
const source = SOURCE_ROOTS.find((s) => s.id === id)
if (!source) return ''
@@ -141,15 +138,15 @@ function copyLevelDbDir(from: string, to: string): boolean {
interface SourceKeyInfo {
key: string | null
/** app-bound(V20)等不可移植场景的说明 */
/** 不可解密场景(非 Windows / app-bound V20 / 缺失)的说明 */
note?: string
}
/**
* 读取源浏览器 Local State 里的 os_crypt.encrypted_key 并判断可移植性。
* Windows:前缀 DPAPI 可移植(文件兜底路径靠移植它;注入路径用它解密);
* V20 为 app-bound 加密(Chrome/Edge 127+),明确降级。
* macOS/Linux:密钥在系统钥匙串/KWallet,Cookies 无法解密,仅迁移 Local Storage。
* 读取源浏览器 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') {
@@ -163,7 +160,7 @@ function readSourceKey(userDataDir: string): SourceKeyInfo {
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,首次访问各站点仍需登录一次。' }
return { key: null, note: '该浏览器启用了应用绑定加密(app-bound),Cookie 无法第三方迁移;LocalStorage 已导入,需登录的站点在受管浏览器里登录一次即可。' }
}
if (bytes.subarray(0, 5).toString('utf8') !== 'DPAPI') {
return { key: null, note: '源浏览器 Cookie 密钥格式不受支持,跳过 Cookie 迁移。' }
@@ -214,36 +211,24 @@ function decryptChromeBlob(encrypted: Buffer, key: Buffer): string {
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`
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')
/** 源 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
/** 已移植源密钥(Cookie 文件兜底路径在重启后由本应用 Chromium 解密需要它) */
transplanted: boolean
}
/**
* collect:源 profile → 暂存目录(分区布局:Local Storage/leveldb + Network/Cookies)。
* 源文件被占用时抛出可操作的提示;Cookie 失败不阻塞(降级只导 Local Storage)。
* collect:源 profile → 暂存目录(Local Storage/leveldb + Network/Cookies)。
* 源文件被占用时抛出可操作的提示;Cookie 拷贝失败不阻塞(降级只导 Local Storage)。
*/
function collectFromSource(root: string, sourceProfile: string, staging: string): CollectedImport {
let localStorage = false
@@ -258,7 +243,6 @@ 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')
@@ -267,8 +251,6 @@ function collectFromSource(root: string, sourceProfile: string, staging: string)
: null
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']) {
@@ -286,53 +268,42 @@ function collectFromSource(root: string, sourceProfile: string, staging: string)
cookieNote = keyInfo.note ?? '源浏览器没有可迁移的 Cookie 数据库。'
}
return { cookies, localStorage, cookieNote, transplanted }
return { cookies, localStorage, cookieNote }
}
/** apply(LocalStorage):暂存 → 受管分区(分区被本应用 Chromium 锁住时抛原始 lock 错误) */
function applyStagedLocalStorage(staging: string): void {
copyLevelDbDir(join(staging, 'Local Storage', 'leveldb'), join(partitionDir(), 'Local Storage', 'leveldb'))
/** apply(LocalStorage):暂存 → 指定分区目录(目标应是未被 Chromium 打开过的目录) */
function applyStagedLocalStorage(staging: string, targetPartitionDir: string): void {
copyLevelDbDir(join(staging, 'Local Storage', 'leveldb'), join(targetPartitionDir, 'Local Storage', 'leveldb'))
}
/** apply(Cookies 文件兜底):仅密钥已移植时有效(重启后 Chromium 用移植密钥解密) */
function applyStagedCookies(staging: string): void {
const cookieDb = join(staging, 'Network', 'Cookies')
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 }
type SourceCookieRead =
| { kind: 'ok'; cookies: BrowserCookieSeed[]; skipped: number }
| { kind: 'unsupported'; note: string }
/**
* Cookie 热注入:解密暂存 Cookies 数据库并逐条写入分区会话(不碰文件,无需重启)。
* 返回 unsupported 表示当前环境无法解密(非 Windows / 无密钥 / DPAPI 失败)。
* 解密暂存的源 Cookies 数据库 → 明文 Cookie 种子列表(之后经会话 API 注入,
* 不碰分区文件,天然无锁)。返回 unsupported 表示当前环境无法解密。
*/
async function injectCookiesFromStaging(root: string, staging: string): Promise<CookieInjectResult> {
async function readSourceCookies(root: string, staging: string): Promise<SourceCookieRead> {
if (process.platform !== 'win32') {
return { kind: 'unsupported', note: '当前平台 Cookie 密钥绑定系统钥匙串,Cookies 将以文件方式在重启后自动落盘。' }
return { kind: 'unsupported', note: '当前平台的浏览器 Cookie 密钥绑定系统钥匙串,暂不支持迁移 Cookie(LocalStorage 登录态仍会导入)。' }
}
const keyInfo = readSourceKey(root)
if (!keyInfo.key) return { kind: 'unsupported', note: keyInfo.note ?? '无可用 Cookie 密钥,跳过 Cookie 热注入。' }
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),Cookies 将以文件方式在重启后自动落盘。' }
return { kind: 'unsupported', note: 'Cookie 密钥解密失败(DPAPI),本次未导入 Cookie,可稍后重试。' }
}
const db = new DatabaseSync(join(staging, 'Network', 'Cookies'), { readOnly: true })
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 FROM cookies')
.prepare('SELECT host_key, name, value, encrypted_value, path, is_secure, is_httponly, expires_utc, samesite FROM cookies')
.all()
const controller = getBrowserController()
let injected = 0
let failed = 0
const cookies: BrowserCookieSeed[] = []
let skipped = 0
for (const row of rows) {
try {
const r = row as {
@@ -344,67 +315,52 @@ async function injectCookiesFromStaging(root: string, staging: string): Promise<
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) {
failed++
skipped++
continue
}
const secure = r.is_secure === 1
const details: Electron.CookiesSetDetails = {
url: `${secure ? 'https' : 'http'}://${r.host_key.replace(/^\./, '')}${r.path || '/'}`,
cookies.push({
name: r.name,
value,
domain: r.host_key,
hostOnly: !r.host_key.startsWith('.'),
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++
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 {
failed++
skipped++
}
}
return { kind: 'ok', injected, failed }
return { kind: 'ok', cookies, skipped }
} finally {
db.close()
}
}
/**
* 应用启动早期调用(分区尚未被 Chromium 打开):完成上次 Cookie 文件兜底
* 路径搁置的待办导入。失败则保留待办,下次启动重试。
*/
export function applyPendingProfileImport(): void {
const pending = pendingDir()
const metaFile = join(pending, IMPORT_META_FILE)
if (!existsSync(metaFile)) return
/** 导入互斥:同一时刻只允许一个导入在跑(分区轮换不是并发安全的) */
let importInFlight = false
/** 导入指定浏览器的 profile 配置到受管浏览器分区(无感热应用:无需重启应用,无搁置路径) */
export async function importBrowserProfile(sourceId: string, profileDir: string): Promise<BrowserImportResult> {
if (importInFlight) throw new Error('已有导入正在进行,请稍候。')
importInFlight = true
try {
const meta = JSON.parse(readFileSync(metaFile, 'utf8')) as BrowserImportInfo
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 })
} catch (err) {
if (err instanceof SyntaxError) {
// meta 损坏:无法解读的待办只能放弃
rmSync(pending, { recursive: true, force: true })
log.error('pending profile import meta corrupted, dropped', { err: err instanceof Error ? err.message : String(err) })
return
}
// 分区仍被占用等暂时性失败:保留待办,下次启动重试
log.warn('pending profile import deferred', { err: err instanceof Error ? err.message : String(err) })
return await importBrowserProfileInner(sourceId, profileDir)
} finally {
importInFlight = false
}
}
/** 导入指定浏览器的 profile 配置到受管浏览器分区(热应用:通常无需重启应用) */
export async function importBrowserProfile(sourceId: string, profileDir: string): Promise<BrowserImportResult> {
async function importBrowserProfileInner(sourceId: string, profileDir: string): Promise<BrowserImportResult> {
const browser = getBrowserController()
const root = sourceRoot(sourceId)
if (!root || !existsSync(root)) throw new Error('未找到该浏览器的用户数据目录。')
@@ -417,8 +373,8 @@ 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:源 → 暂存目录(源数据先抓到手;Cookies 文件作为解密源与兜底)
const staging = join(app.getPath('userData'), 'Partitions', `.import-staging-${Date.now()}`)
// 1) collect + 源 Cookie 解密(源数据全部先抓到手,之后源浏览器锁不锁都无所谓)
const staging = join(partitionsRoot(), `.import-staging-${Date.now()}`)
let collected: CollectedImport
try {
mkdirSync(staging, { recursive: true })
@@ -427,144 +383,98 @@ export async function importBrowserProfile(sourceId: string, profileDir: string)
rmSync(staging, { recursive: true, force: true })
throw err
}
const sourceRead: SourceCookieRead = collected.cookieNote
? { kind: 'unsupported', note: collected.cookieNote }
: await readSourceCookies(root, staging)
// 2) Local Storage 热应用:被分区占用时执行内核热重启(销毁标签 + 释放存储句柄)
let restarted = false
// 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)
applyStagedLocalStorage(staging, partitionDirOf(browser.partitionName()))
} catch (err) {
if (!isLockError(err)) {
rmSync(staging, { recursive: true, force: true })
throw new Error(`导入写入失败: ${err instanceof Error ? err.message : String(err)}`)
}
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 }
}
browser.restoreTabs(urls)
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,
cookies: true,
localStorage: collected.localStorage,
cookieNote: collected.cookieNote,
message: `已从 ${sourceName} 导入 Cookie + LocalStorage${restarted ? '(浏览器内核已热重启)' : ''}。若登录态未生效,重启应用后重试。`
}
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 })
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({
const info: BrowserImportInfo = {
sourceId,
browser: sourceName,
profile: profileName,
profileDir: dirName,
at: new Date().toISOString(),
cookies: false,
localStorage: true,
note: inject.note
})
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: false,
localStorage: true,
cookieNote: inject.note,
message: `已从 ${sourceName} 导入 LocalStorage。${inject.note ?? ''}`
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 })
}