488 lines
21 KiB
TypeScript
488 lines
21 KiB
TypeScript
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 })
|
||
}
|