fix(browser): 导入 EPERM 修复——两段式迁移 + 分区占用时转待办重启生效
This commit is contained in:
@@ -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 ? '若登录态未生效,重启应用后重试。' : ''
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user