From cf2cc1cb776543b16bcf4536d4bebe20ce248a65 Mon Sep 17 00:00:00 2001 From: Rick Ye Date: Fri, 11 Sep 2026 18:03:03 +0800 Subject: [PATCH] =?UTF-8?q?fix(browser):=20=E5=AF=BC=E5=85=A5=20EPERM=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E2=80=94=E2=80=94=E4=B8=A4=E6=AE=B5=E5=BC=8F?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=20+=20=E5=88=86=E5=8C=BA=E5=8D=A0=E7=94=A8?= =?UTF-8?q?=E6=97=B6=E8=BD=AC=E5=BE=85=E5=8A=9E=E9=87=8D=E5=90=AF=E7=94=9F?= =?UTF-8?q?=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/browser/browser-profile.ts | 226 +++++++++++++----- apps/desktop/src/main/ipc.ts | 4 +- .../components/browser/BrowserImportForm.tsx | 2 +- 3 files changed, 175 insertions(+), 57 deletions(-) diff --git a/apps/desktop/src/main/browser/browser-profile.ts b/apps/desktop/src/main/browser/browser-profile.ts index 71e062a..b368125 100644 --- a/apps/desktop/src/main/browser/browser-profile.ts +++ b/apps/desktop/src/main/browser/browser-profile.ts @@ -1,9 +1,12 @@ 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 { 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 { getLogger } from '../logger' + +const log = getLogger('browser-profile') /** * 本地浏览器配置导入(复用各网站登录态)。 @@ -16,6 +19,13 @@ import { getBrowserController } from './browser-controller' * 本应用内的 Chromium 即可解密拷贝过来的 Cookies(Chrome 127+ 的 app-bound * 加密 V20 除外,会明确提示不支持)。macOS/Linux 的密钥绑定钥匙串/KWallet, * Cookies 无法直接迁移,仅迁移 Local Storage。 + * + * 两段式迁移(collect → apply): + * 1) collect:源 profile → 暂存目录(此刻把数据抓到手,源浏览器之后锁不锁都无所谓); + * 2) apply:暂存目录 → 受管分区。分区数据可能被本应用自己的 Chromium 锁住 + * (本次运行打开过网页,leveldb 句柄即使关掉标签也不释放),此时不硬刚—— + * 暂存目录转正为待办任务(pending),applyPendingProfileImport 在下次启动 + * 早期(分区尚未被 Chromium 打开)自动完成迁移。 */ /** Chromium 系浏览器的用户数据根目录(按平台) */ @@ -55,6 +65,13 @@ function partitionDir(): string { 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 { const source = SOURCE_ROOTS.find((s) => s.id === id) if (!source) return '' @@ -99,16 +116,13 @@ export function detectBrowserSources(): BrowserSource[] { return sources } -function copyFileSafe(from: string, to: string): void { - try { - copyFileSync(from, to) - } catch (err) { - const code = (err as NodeJS.ErrnoException).code - if (code === 'EBUSY' || code === 'EPERM' || code === 'EACCES') { - throw new Error('源浏览器文件被占用:请完全退出对应的浏览器(含后台进程)后重试。') - } - throw err - } +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 运行时文件),目标已存在时先清空 */ @@ -120,7 +134,7 @@ function copyLevelDbDir(from: string, to: string): boolean { rmSync(to, { recursive: true, force: true }) mkdirSync(to, { recursive: true }) 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 } @@ -178,32 +192,27 @@ function transplantKey(sourceKey: string): void { writeFileSync(appLocalState, JSON.stringify(doc, null, 2), 'utf8') } -/** 导入指定浏览器的 profile 配置到受管浏览器分区 */ -export function importBrowserProfile(sourceId: string, profileDir: string): 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 target = partitionDir() +interface CollectedImport { + cookies: boolean + localStorage: boolean + cookieNote?: string +} - // 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 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) { - 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 cookieNote: string | undefined const keyInfo = readSourceKey(root) @@ -215,42 +224,149 @@ export function importBrowserProfile(sourceId: string, profileDir: string): Brow if (keyInfo.key && cookieDb) { try { transplantKey(keyInfo.key) - mkdirSync(join(target, 'Network'), { recursive: true }) - copyFileSafe(cookieDb, join(target, 'Network', 'Cookies')) + mkdirSync(join(staging, 'Network'), { recursive: true }) + copyFile(cookieDb, join(staging, 'Network', 'Cookies')) 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 } catch (err) { - cookieNote = err instanceof Error ? err.message : 'Cookie 迁移失败。' + cookieNote = isLockError(err) + ? '源浏览器 Cookie 文件被占用:请完全退出该浏览器后重试。' + : err instanceof Error + ? err.message + : 'Cookie 迁移失败。' } } else { cookieNote = keyInfo.note ?? '源浏览器没有可迁移的 Cookie 数据库。' } - const parts: string[] = [] - if (cookies) parts.push('Cookie(站点登录态)') - if (localStorage) parts.push('LocalStorage') - if (!cookies && !localStorage) { - return { ok: false, cookies, localStorage, cookieNote, message: `未在 ${sourceName}(${dirName})中找到可迁移的登录数据。` } + return { cookies, localStorage, cookieNote } +} + +/** apply:暂存目录 → 受管分区(分区被本应用 Chromium 锁住时抛原始 lock 错误) */ +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 { + 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({ - browser: sourceName, - profile: readProfileName(sourceProfile), - at: new Date().toISOString(), - cookies, - localStorage, - note: cookieNote - }) + // 2) apply:暂存 → 受管分区;分区被本应用 Chromium 占用时转待办(重启后自动完成) + try { + applyStagedToPartition(staging) + } catch (err) { + rmSync(staging, { recursive: true, force: true }) + if (!isLockError(err)) { + 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 { ok: true, - cookies, - localStorage, - cookieNote, - message: `已从 ${sourceName} 导入 ${parts.join(' + ')}。${cookieNote ?? ''}${ - cookies ? '若登录态未生效,重启应用后重试。' : '' + cookies: collected.cookies, + localStorage: collected.localStorage, + cookieNote: collected.cookieNote, + message: `已从 ${sourceName} 导入 ${parts.join(' + ')}。${collected.cookieNote ?? ''}${ + collected.cookies ? '若登录态未生效,重启应用后重试。' : '' }` } } diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 07067d3..f4a2871 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -4,7 +4,7 @@ import { userInfo } from 'node:os' import { AgentService } from './pi/agent-service' import { getPluginHost } from './plugins/plugin-host' 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' const log = getLogger('ipc') @@ -422,6 +422,8 @@ export function registerIpc(): AgentService { // ---- 插件系统 ---- void app.whenReady().then(() => { + // 上次搁置的浏览器数据导入在此刻应用(分区尚未被 Chromium 打开,文件必然可写) + applyPendingProfileImport() // 插件启停/重载会重建 agent 运行时(extensionFactories 重跑) getPluginHost().setRuntimeRebuilder(() => service.rebuildRuntime()) }) diff --git a/apps/desktop/src/renderer/src/components/browser/BrowserImportForm.tsx b/apps/desktop/src/renderer/src/components/browser/BrowserImportForm.tsx index f5bc5d8..96d92e7 100644 --- a/apps/desktop/src/renderer/src/components/browser/BrowserImportForm.tsx +++ b/apps/desktop/src/renderer/src/components/browser/BrowserImportForm.tsx @@ -135,7 +135,7 @@ export function BrowserImportForm(): JSX.Element { 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" > - {busy ? '导入中…' : '导入登录态'} + {busy ? '导入中…' : '导入浏览器数据'}