fix(browser): 导入 EPERM 修复——两段式迁移 + 分区占用时转待办重启生效

This commit is contained in:
Rick Ye
2026-09-11 18:03:03 +08:00
parent b1405ee71f
commit cf2cc1cb77
3 changed files with 175 additions and 57 deletions
+170 -54
View File
@@ -1,9 +1,12 @@
import { app } from 'electron' import { app } from 'electron'
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os' import { homedir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import type { BrowserImportResult, BrowserProfileMeta, BrowserSource } from '@shared/types' import type { BrowserImportInfo, BrowserImportResult, BrowserProfileMeta, BrowserSource } from '@shared/types'
import { getBrowserController } from './browser-controller' import { getBrowserController } from './browser-controller'
import { getLogger } from '../logger'
const log = getLogger('browser-profile')
/** /**
* 本地浏览器配置导入(复用各网站登录态)。 * 本地浏览器配置导入(复用各网站登录态)。
@@ -16,6 +19,13 @@ import { getBrowserController } from './browser-controller'
* 本应用内的 Chromium 即可解密拷贝过来的 Cookies(Chrome 127+ 的 app-bound * 本应用内的 Chromium 即可解密拷贝过来的 Cookies(Chrome 127+ 的 app-bound
* 加密 V20 除外,会明确提示不支持)。macOS/Linux 的密钥绑定钥匙串/KWallet, * 加密 V20 除外,会明确提示不支持)。macOS/Linux 的密钥绑定钥匙串/KWallet,
* Cookies 无法直接迁移,仅迁移 Local Storage。 * Cookies 无法直接迁移,仅迁移 Local Storage。
*
* 两段式迁移(collect → apply):
* 1) collect:源 profile → 暂存目录(此刻把数据抓到手,源浏览器之后锁不锁都无所谓);
* 2) apply:暂存目录 → 受管分区。分区数据可能被本应用自己的 Chromium 锁住
* (本次运行打开过网页,leveldb 句柄即使关掉标签也不释放),此时不硬刚——
* 暂存目录转正为待办任务(pending),applyPendingProfileImport 在下次启动
* 早期(分区尚未被 Chromium 打开)自动完成迁移。
*/ */
/** Chromium 系浏览器的用户数据根目录(按平台) */ /** Chromium 系浏览器的用户数据根目录(按平台) */
@@ -55,6 +65,13 @@ function partitionDir(): string {
return join(app.getPath('userData'), 'Partitions', 'aso-browser') return join(app.getPath('userData'), 'Partitions', 'aso-browser')
} }
/** 待办导入目录(分区被占用时的暂存转正;启动早期自动应用) */
function pendingDir(): string {
return join(app.getPath('userData'), 'Partitions', 'aso-browser.pending-import')
}
const IMPORT_META_FILE = 'import-meta.json'
function sourceRoot(id: string): string { function sourceRoot(id: string): string {
const source = SOURCE_ROOTS.find((s) => s.id === id) const source = SOURCE_ROOTS.find((s) => s.id === id)
if (!source) return '' if (!source) return ''
@@ -99,16 +116,13 @@ export function detectBrowserSources(): BrowserSource[] {
return sources return sources
} }
function copyFileSafe(from: string, to: string): void { function isLockError(err: unknown): boolean {
try {
copyFileSync(from, to)
} catch (err) {
const code = (err as NodeJS.ErrnoException).code const code = (err as NodeJS.ErrnoException).code
if (code === 'EBUSY' || code === 'EPERM' || code === 'EACCES') { return code === 'EBUSY' || code === 'EPERM' || code === 'EACCES'
throw new Error('源浏览器文件被占用:请完全退出对应的浏览器(含后台进程)后重试。') }
}
throw err function copyFile(from: string, to: string): void {
} copyFileSync(from, to)
} }
/** 拷贝 leveldb 目录(跳过 LOCK/LOG 运行时文件),目标已存在时先清空 */ /** 拷贝 leveldb 目录(跳过 LOCK/LOG 运行时文件),目标已存在时先清空 */
@@ -120,7 +134,7 @@ function copyLevelDbDir(from: string, to: string): boolean {
rmSync(to, { recursive: true, force: true }) rmSync(to, { recursive: true, force: true })
mkdirSync(to, { recursive: true }) mkdirSync(to, { recursive: true })
for (const file of files) { for (const file of files) {
copyFileSafe(join(from, file.name), join(to, file.name)) copyFile(join(from, file.name), join(to, file.name))
} }
return true return true
} }
@@ -178,32 +192,27 @@ function transplantKey(sourceKey: string): void {
writeFileSync(appLocalState, JSON.stringify(doc, null, 2), 'utf8') writeFileSync(appLocalState, JSON.stringify(doc, null, 2), 'utf8')
} }
/** 导入指定浏览器的 profile 配置到受管浏览器分区 */ interface CollectedImport {
export function importBrowserProfile(sourceId: string, profileDir: string): BrowserImportResult { cookies: boolean
const browser = getBrowserController() localStorage: boolean
if (browser.hasTabs) { cookieNote?: string
throw new Error('请先关闭内置浏览器的所有标签页,再导入浏览器配置。') }
}
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 target = partitionDir()
// 1) Local Storage(明文 leveldb,直接迁移) /**
* collect:源 profile → 暂存目录(分区布局:Local Storage/leveldb + Network/Cookies)。
* 源文件被占用时抛出可操作的提示;Cookie 失败不阻塞(降级只导 Local Storage)。
*/
function collectFromSource(root: string, sourceProfile: string, staging: string): CollectedImport {
let localStorage = false let localStorage = false
try { try {
localStorage = copyLevelDbDir(join(sourceProfile, 'Local Storage', 'leveldb'), join(target, 'Local Storage', 'leveldb')) localStorage = copyLevelDbDir(join(sourceProfile, 'Local Storage', 'leveldb'), join(staging, 'Local Storage', 'leveldb'))
} catch (err) { } catch (err) {
throw new Error(err instanceof Error ? err.message : `Local Storage 迁移失败: ${String(err)}`) if (isLockError(err)) {
throw new Error('源浏览器文件被占用:请完全退出对应的浏览器(含后台进程)后重试。')
}
throw new Error(`Local Storage 迁移失败: ${err instanceof Error ? err.message : String(err)}`)
} }
// 2) Cookies + 密钥移植
let cookies = false let cookies = false
let cookieNote: string | undefined let cookieNote: string | undefined
const keyInfo = readSourceKey(root) const keyInfo = readSourceKey(root)
@@ -215,42 +224,149 @@ export function importBrowserProfile(sourceId: string, profileDir: string): Brow
if (keyInfo.key && cookieDb) { if (keyInfo.key && cookieDb) {
try { try {
transplantKey(keyInfo.key) transplantKey(keyInfo.key)
mkdirSync(join(target, 'Network'), { recursive: true }) mkdirSync(join(staging, 'Network'), { recursive: true })
copyFileSafe(cookieDb, join(target, 'Network', 'Cookies')) copyFile(cookieDb, join(staging, 'Network', 'Cookies'))
for (const suffix of ['-wal', '-shm']) { for (const suffix of ['-wal', '-shm']) {
if (existsSync(`${cookieDb}${suffix}`)) copyFileSafe(`${cookieDb}${suffix}`, join(target, 'Network', `Cookies${suffix}`)) if (existsSync(`${cookieDb}${suffix}`)) copyFile(`${cookieDb}${suffix}`, join(staging, 'Network', `Cookies${suffix}`))
} }
cookies = true cookies = true
} catch (err) { } catch (err) {
cookieNote = err instanceof Error ? err.message : 'Cookie 迁移失败。' cookieNote = isLockError(err)
? '源浏览器 Cookie 文件被占用:请完全退出该浏览器后重试。'
: err instanceof Error
? err.message
: 'Cookie 迁移失败。'
} }
} else { } else {
cookieNote = keyInfo.note ?? '源浏览器没有可迁移的 Cookie 数据库。' cookieNote = keyInfo.note ?? '源浏览器没有可迁移的 Cookie 数据库。'
} }
const parts: string[] = [] return { cookies, localStorage, cookieNote }
if (cookies) parts.push('Cookie(站点登录态)') }
if (localStorage) parts.push('LocalStorage')
if (!cookies && !localStorage) { /** apply:暂存目录 → 受管分区(分区被本应用 Chromium 锁住时抛原始 lock 错误) */
return { ok: false, cookies, localStorage, cookieNote, message: `未在 ${sourceName}(${dirName})中找到可迁移的登录数据。` } function applyStagedToPartition(staging: string): void {
const target = partitionDir()
copyLevelDbDir(join(staging, 'Local Storage', 'leveldb'), join(target, 'Local Storage', 'leveldb'))
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}`))
}
}
}
/**
* 应用启动早期调用(分区尚未被 Chromium 打开):完成上次因分区被占用
* 而搁置的待办导入。失败则保留待办,下次启动重试。
*/
export function applyPendingProfileImport(): void {
const pending = pendingDir()
const metaFile = join(pending, IMPORT_META_FILE)
if (!existsSync(metaFile)) return
try {
const meta = JSON.parse(readFileSync(metaFile, 'utf8')) as BrowserImportInfo
applyStagedToPartition(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) })
}
}
/** 导入指定浏览器的 profile 配置到受管浏览器分区 */
export async function importBrowserProfile(sourceId: string, profileDir: string): Promise<BrowserImportResult> {
const browser = getBrowserController()
if (browser.hasTabs) {
throw new Error('请先关闭内置浏览器的所有标签页,再导入浏览器数据。')
}
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:源 → 暂存目录(源数据先抓到手)
const staging = join(app.getPath('userData'), 'Partitions', `.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 info: BrowserImportInfo = {
browser: sourceName,
profile: profileName,
at: new Date().toISOString(),
cookies: collected.cookies,
localStorage: collected.localStorage,
note: collected.cookieNote
} }
browser.setImportedInfo({ // 2) apply:暂存 → 受管分区;分区被本应用 Chromium 占用时转待办(重启后自动完成)
browser: sourceName, try {
profile: readProfileName(sourceProfile), applyStagedToPartition(staging)
at: new Date().toISOString(), } catch (err) {
cookies, rmSync(staging, { recursive: true, force: true })
localStorage, if (!isLockError(err)) {
note: cookieNote throw new Error(`导入写入失败: ${err instanceof Error ? err.message : String(err)}`)
}) }
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 })
return {
ok: true,
cookies: collected.cookies,
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})中找到可迁移的登录数据。`
}
}
browser.setImportedInfo(info)
return { return {
ok: true, ok: true,
cookies, cookies: collected.cookies,
localStorage, localStorage: collected.localStorage,
cookieNote, cookieNote: collected.cookieNote,
message: `已从 ${sourceName} 导入 ${parts.join(' + ')}。${cookieNote ?? ''}${ message: `已从 ${sourceName} 导入 ${parts.join(' + ')}。${collected.cookieNote ?? ''}${
cookies ? '若登录态未生效,重启应用后重试。' : '' collected.cookies ? '若登录态未生效,重启应用后重试。' : ''
}` }`
} }
} }
+3 -1
View File
@@ -4,7 +4,7 @@ import { userInfo } from 'node:os'
import { AgentService } from './pi/agent-service' import { AgentService } from './pi/agent-service'
import { getPluginHost } from './plugins/plugin-host' import { getPluginHost } from './plugins/plugin-host'
import { getBrowserController } from './browser/browser-controller' import { getBrowserController } from './browser/browser-controller'
import { detectBrowserSources, importBrowserProfile } from './browser/browser-profile' import { applyPendingProfileImport, detectBrowserSources, importBrowserProfile } from './browser/browser-profile'
import { getLogger } from './logger' import { getLogger } from './logger'
const log = getLogger('ipc') const log = getLogger('ipc')
@@ -422,6 +422,8 @@ export function registerIpc(): AgentService {
// ---- 插件系统 ---- // ---- 插件系统 ----
void app.whenReady().then(() => { void app.whenReady().then(() => {
// 上次搁置的浏览器数据导入在此刻应用(分区尚未被 Chromium 打开,文件必然可写)
applyPendingProfileImport()
// 插件启停/重载会重建 agent 运行时(extensionFactories 重跑) // 插件启停/重载会重建 agent 运行时(extensionFactories 重跑)
getPluginHost().setRuntimeRebuilder(() => service.rebuildRuntime()) getPluginHost().setRuntimeRebuilder(() => service.rebuildRuntime())
}) })
@@ -135,7 +135,7 @@ export function BrowserImportForm(): JSX.Element {
onClick={runImport} onClick={runImport}
className="rounded bg-accent px-3.5 py-1.5 text-[12px] font-medium text-page transition-opacity hover:opacity-90 disabled:opacity-40" className="rounded bg-accent px-3.5 py-1.5 text-[12px] font-medium text-page transition-opacity hover:opacity-90 disabled:opacity-40"
> >
{busy ? '导入中…' : '导入登录态'} {busy ? '导入中…' : '导入浏览器数据'}
</button> </button>
</div> </div>
</div> </div>