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
@@ -30,14 +30,17 @@ import {
* 渲染层 BrowserSlot 量出占位矩形,经 IPC 同步边界(原生视图天然盖在 DOM 之上)。 * 渲染层 BrowserSlot 量出占位矩形,经 IPC 同步边界(原生视图天然盖在 DOM 之上)。
* - 页面观察/操作全部走 CDP(webContents.debugger):AX tree 快照 + ref 代际机制 * - 页面观察/操作全部走 CDP(webContents.debugger):AX tree 快照 + ref 代际机制
* + Input.dispatch* 真实输入事件;不在页面里执行模拟点击。 * + Input.dispatch* 真实输入事件;不在页面里执行模拟点击。
* - 登录态隔离在 persist:aso-browser 分区;本地浏览器配置导入由 browser-profile.ts * - 登录态隔离在受管分区;本地浏览器配置导入由 browser-profile.ts 完成:
* 直接迁移分区目录数据(Cookies/Local Storage + 密钥移植)。 * 分区轮换(aso-browser-g<n>)+ Cookie 会话 API 热注入,全程无需重启应用。
* - 代理设置作用于同一分区会话(session.setProxy):跟随系统 / 直连 / 手动规则 * - 代理设置作用于同一分区会话(session.setProxy):跟随系统 / 直连 / 手动规则
* (Chromium proxyRules 语法 + 绕过列表),持久化进 aso-browser.json, * (Chromium proxyRules 语法 + 绕过列表),持久化进 aso-browser.json,
* 启动即应用、修改后断开在途连接立即生效(对 Agent 的 browser_* 工具同样生效)。 * 启动即应用、修改后断开在途连接立即生效(对 Agent 的 browser_* 工具同样生效)。
*/ */
const PARTITION = 'persist:aso-browser' // 受管分区名(persist: 前缀之外的磁盘目录名):gen 0 为初始名,导入时轮换 +1
function partitionNameOf(gen: number): string {
return gen <= 0 ? 'aso-browser' : `aso-browser-g${gen}`
}
// 新标签页默认地址:google.com 在目标市场(国内)不可达,新开标签会长时间白屏, // 新标签页默认地址:google.com 在目标市场(国内)不可达,新开标签会长时间白屏,
// 误以为浏览器故障;用可稳定访问的导航页 // 误以为浏览器故障;用可稳定访问的导航页
const NEW_TAB_URL = 'https://www.baidu.com/' const NEW_TAB_URL = 'https://www.baidu.com/'
@@ -75,6 +78,23 @@ interface BrowserSettings {
riskAcknowledged: boolean riskAcknowledged: boolean
imported?: BrowserImportInfo | null imported?: BrowserImportInfo | null
proxy?: BrowserProxySettings proxy?: BrowserProxySettings
/** 受管分区代号:导入时轮换 +1;旧分区目录由启动清理回收 */
partitionGen?: number
}
/** 可注入会话的 Cookie 种子(源浏览器解密结果 / 旧分区导出结果;主进程内部用) */
export interface BrowserCookieSeed {
name: string
value: string
/** Chromium 语义:以 . 开头 = 域 Cookie;hostOnly = 仅绑定该主机 */
domain: string
hostOnly?: boolean
path?: string
secure?: boolean
httpOnly?: boolean
/** Unix 秒;缺省 = 会话 Cookie */
expirationDate?: number
sameSite?: 'unspecified' | 'no_restriction' | 'lax' | 'strict'
} }
export interface BrowserObservation { export interface BrowserObservation {
@@ -323,19 +343,28 @@ export class BrowserController {
private closedTabs: Array<{ title: string; url: string; closedAt: number }> = [] private closedTabs: Array<{ title: string; url: string; closedAt: number }> = []
constructor() { constructor() {
this.browserSession = electronSession.fromPartition(PARTITION) this.settings = this.loadSettings()
this.browserSession = this.createBrowserSession()
}
/** 当前受管分区名(persist: 之外的磁盘目录名;导入文件迁移与启动清理按它定位) */
partitionName(): string {
return partitionNameOf(this.settings.partitionGen ?? 0)
}
/** 创建/重建受管分区会话并挂上固定策略(UA、权限、下载、代理) */
private createBrowserSession(): Session {
const session = electronSession.fromPartition(`persist:${this.partitionName()}`)
// 诚实的 UA:保留 Chromium token 保证站点兼容,去掉 Electron 标识 // 诚实的 UA:保留 Chromium token 保证站点兼容,去掉 Electron 标识
this.browserSession.setUserAgent( session.setUserAgent(session.getUserAgent().replace(/Electron\/[\d.]+/, `InkreachAgent/${app.getVersion()}`))
this.browserSession.getUserAgent().replace(/Electron\/[\d.]+/, `InkreachAgent/${app.getVersion()}`)
)
// 网页权限请求(摄像头/定位等)默认拒绝 // 网页权限请求(摄像头/定位等)默认拒绝
this.browserSession.setPermissionRequestHandler((_wc, _permission, callback) => callback(false)) session.setPermissionRequestHandler((_wc, _permission, callback) => callback(false))
// 下载留在受管浏览器内,落到系统下载目录(重名自动加序号) // 下载留在受管浏览器内,落到系统下载目录(重名自动加序号)
this.browserSession.on('will-download', (_event, item) => { session.on('will-download', (_event, item) => {
const dir = app.getPath('downloads') const dir = app.getPath('downloads')
const base = item.getFilename() || 'download' const base = item.getFilename() || 'download'
const dot = base.lastIndexOf('.') const dot = base.lastIndexOf('.')
const stem = dot > 0 ? base.slice(0, dot) : base const stem = dot > 0 ? base.slice(0, dot) : ''
const ext = dot > 0 ? base.slice(dot) : '' const ext = dot > 0 ? base.slice(dot) : ''
let target = join(dir, base) let target = join(dir, base)
for (let i = 1; existsSync(target) && i < 100; i++) { for (let i = 1; existsSync(target) && i < 100; i++) {
@@ -343,11 +372,11 @@ export class BrowserController {
} }
item.setSavePath(target) item.setSavePath(target)
}) })
this.settings = this.loadSettings()
// 启动即应用持久化的代理设置(setProxy 异步;失败不阻塞浏览器初始化,日志可查) // 启动即应用持久化的代理设置(setProxy 异步;失败不阻塞浏览器初始化,日志可查)
this.applyProxy(this.proxySettings()).catch((err: unknown) => { this.applyProxyTo(session, this.proxySettings()).catch((err: unknown) => {
log.error('启动时代理设置应用失败:', { err }) log.error('启动时代理设置应用失败:', { err })
}) })
return session
} }
// ---- 设置持久化 ---- // ---- 设置持久化 ----
@@ -360,16 +389,20 @@ export class BrowserController {
try { try {
const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as Partial<BrowserSettings> const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as Partial<BrowserSettings>
const proxy = raw.proxy const proxy = raw.proxy
const imported = raw.imported
// 旧版「搁置/需重启」类提示是一次性状态说明,不该常驻展示——历史残留直接清掉
if (imported?.note && /被占用|暂存|搁置|重启/.test(imported.note)) imported.note = undefined
return { return {
riskAcknowledged: raw.riskAcknowledged === true, riskAcknowledged: raw.riskAcknowledged === true,
imported: raw.imported ?? null, imported: imported ?? null,
proxy: proxy:
proxy && (proxy.mode === 'direct' || proxy.mode === 'fixed' || proxy.mode === 'system') proxy && (proxy.mode === 'direct' || proxy.mode === 'fixed' || proxy.mode === 'system')
? proxy ? proxy
: { mode: 'system' } : { mode: 'system' },
partitionGen: typeof raw.partitionGen === 'number' && raw.partitionGen >= 0 ? Math.floor(raw.partitionGen) : 0
} }
} catch { } catch {
return { riskAcknowledged: false, proxy: { mode: 'system' } } return { riskAcknowledged: false, proxy: { mode: 'system' }, partitionGen: 0 }
} }
} }
@@ -392,19 +425,27 @@ export class BrowserController {
return this.settings.imported ?? null return this.settings.imported ?? null
} }
/** 内核级热重启(导入热应用用):销毁全部标签、清空分区存储后端 /** 分区轮换(导入无感应用的核心):切换到从未被打开过的下一代分区目录。
* (Chromium 释放 leveldb/cookie 数据句柄),返回被销毁标签的 URL 供恢复 */ * 旧分区的 leveldb / Cookies 文件句柄在本进程内无法释放(clearStorageData
async restartKernel(): Promise<string[]> { * 只清数据不关句柄),文件热替换必然 EPERM;新目录零句柄零锁,文件直接
* 写入即生效。旧目录由启动清理回收。 */
rotatePartition(): void {
this.settings.partitionGen = (this.settings.partitionGen ?? 0) + 1
this.saveSettings()
this.browserSession = this.createBrowserSession()
}
/** 导入前关闭全部标签并返回 URL(导入完成后恢复);不清存储——数据随分区轮换迁移 */
closeForImport(): string[] {
const urls = [...this.tabs.values()] const urls = [...this.tabs.values()]
.sort((a, b) => a.createdAt - b.createdAt) .sort((a, b) => a.createdAt - b.createdAt)
.map((t) => t.url) .map((t) => t.url)
.filter((u) => /^https?:\/\//.test(u)) .filter((u) => /^https?:\/\//.test(u))
this.close() this.close()
await this.browserSession.clearStorageData({ storages: ['localstorage', 'cookies', 'indexdb'] })
return urls return urls
} }
/** 恢复内核热重启前打开的标签 */ /** 恢复导入前打开的标签 */
restoreTabs(urls: string[]): void { restoreTabs(urls: string[]): void {
for (const url of urls) { for (const url of urls) {
try { try {
@@ -415,9 +456,48 @@ export class BrowserController {
} }
} }
/** 热注入单条 Cookie(导入路径:源数据经解密后逐条写入分区会话) */ /** 导出当前分区全部 Cookie(会话 API 返回明文;分区轮换前快照,导入后写回新分区) */
async setCookie(details: Electron.CookiesSetDetails): Promise<void> { async exportCookies(): Promise<BrowserCookieSeed[]> {
await this.browserSession.cookies.set(details) const list = await this.browserSession.cookies.get({})
return list.map((c) => ({
name: c.name,
value: c.value,
domain: c.domain ?? '',
hostOnly: c.hostOnly === true,
path: c.path || '/',
secure: c.secure === true,
httpOnly: c.httpOnly === true,
...(typeof c.expirationDate === 'number' ? { expirationDate: c.expirationDate } : {}),
...(c.sameSite && c.sameSite !== 'unspecified' ? { sameSite: c.sameSite } : {})
}))
}
/** 批量写入当前分区会话(不落文件、天然无锁;分批并发控制在对会话的冲击) */
async importCookies(seeds: BrowserCookieSeed[]): Promise<{ imported: number; failed: number }> {
let imported = 0
let failed = 0
const CHUNK = 64
for (let i = 0; i < seeds.length; i += CHUNK) {
const results = await Promise.allSettled(
seeds.slice(i, i + CHUNK).map((seed) => {
const host = seed.domain.replace(/^\./, '')
return this.browserSession.cookies.set({
url: `${seed.secure ? 'https' : 'http'}://${host}${seed.path || '/'}`,
name: seed.name,
value: seed.value,
...(seed.hostOnly ? {} : { domain: seed.domain }),
path: seed.path || '/',
secure: seed.secure === true,
httpOnly: seed.httpOnly === true,
...(seed.expirationDate ? { expirationDate: seed.expirationDate } : {}),
...(seed.sameSite ? { sameSite: seed.sameSite } : {})
})
})
)
for (const r of results) if (r.status === 'fulfilled') imported++
else failed++
}
return { imported, failed }
} }
// ---- 代理设置 ---- // ---- 代理设置 ----
@@ -430,33 +510,28 @@ export class BrowserController {
return this.proxySettings() return this.proxySettings()
} }
/** 应用到分区会话;切换后断开在途连接,让新规则对已打开的标签立即生效 */ /** 应用到指定分区会话;切换后断开在途连接,让新规则对已打开的标签立即生效 */
private async applyProxy(proxy: BrowserProxySettings): Promise<void> { private async applyProxyTo(session: Session, proxy: BrowserProxySettings): Promise<void> {
await this.browserSession.setProxy( await session.setProxy(
proxy.mode === 'fixed' proxy.mode === 'fixed'
? { mode: 'fixed_servers', proxyRules: proxy.rules ?? '', proxyBypassRules: proxy.bypass } ? { mode: 'fixed_servers', proxyRules: proxy.rules ?? '', proxyBypassRules: proxy.bypass }
: proxy.mode === 'direct' : proxy.mode === 'direct'
? { mode: 'direct' } ? { mode: 'direct' }
: { mode: 'system' } : { mode: 'system' }
) )
await this.browserSession.closeAllConnections() await session.closeAllConnections()
} }
/** 更新并持久化代理设置:先应用成功才落盘(失败抛错给 UI,原设置保持不变) */ /** 更新并持久化代理设置:先应用成功才落盘(失败抛错给 UI,原设置保持不变) */
async setProxySettings(input: BrowserProxySettings): Promise<BrowserProxySettings> { async setProxySettings(input: BrowserProxySettings): Promise<BrowserProxySettings> {
const next = normalizeProxy(input) const next = normalizeProxy(input)
await this.applyProxy(next) await this.applyProxyTo(this.browserSession, next)
this.settings.proxy = next this.settings.proxy = next
this.saveSettings() this.saveSettings()
this.emitState() this.emitState()
return next return next
} }
/** 当前是否有活跃标签页(导入配置前必须没有) */
get hasTabs(): boolean {
return this.tabs.size > 0
}
// ---- 窗口与状态广播 ---- // ---- 窗口与状态广播 ----
setOwnerWindow(win: BrowserWindow): void { setOwnerWindow(win: BrowserWindow): void {
@@ -560,10 +635,14 @@ export class BrowserController {
if (!this.owner || this.owner.isDestroyed()) throw new Error('主窗口尚未就绪,无法创建浏览器标签。') if (!this.owner || this.owner.isDestroyed()) throw new Error('主窗口尚未就绪,无法创建浏览器标签。')
if (this.tabs.size >= MAX_TABS) throw new Error(`浏览器标签页已达上限(${MAX_TABS}),请先关闭不需要的标签。`) if (this.tabs.size >= MAX_TABS) throw new Error(`浏览器标签页已达上限(${MAX_TABS}),请先关闭不需要的标签。`)
const tabId = `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const tabId = `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
// popup 的 viewOptions 不一定带 partition(会导致弹窗落入默认会话),统一强制当前受管分区
const partition = `persist:${this.partitionName()}`
const view = new WebContentsView( const view = new WebContentsView(
viewOptions ?? { viewOptions
? { ...viewOptions, webPreferences: { ...(viewOptions.webPreferences ?? {}), partition } }
: {
webPreferences: { webPreferences: {
partition: PARTITION, partition,
nodeIntegration: false, nodeIntegration: false,
contextIsolation: true, contextIsolation: true,
sandbox: true, sandbox: true,
+148 -238
View File
@@ -1,31 +1,30 @@
import { app } from 'electron' 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 { createDecipheriv } from 'node:crypto'
import { execFile } from 'node:child_process' import { execFile } from 'node:child_process'
import { DatabaseSync } from 'node:sqlite' import { DatabaseSync } from 'node:sqlite'
import { homedir } from 'node:os' import { homedir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import type { BrowserImportInfo, BrowserImportResult, BrowserProfileMeta, BrowserSource } from '@shared/types' 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' import { getLogger } from '../logger'
const log = getLogger('browser-profile') const log = getLogger('browser-profile')
/** /**
* 本地浏览器配置导入(复用各网站登录态)。 * 本地浏览器配置导入(复用各网站登录态)——全程无感,无需重启应用:
* *
* 迁移策略(collect → apply 两段式):
* 1) collect:源 profile 的 Local Storage(明文 leveldb)与 Cookies 数据库先拷入 * 1) collect:源 profile 的 Local Storage(明文 leveldb)与 Cookies 数据库先拷入
* 暂存目录——此刻把数据抓到手,源浏览器之后锁不锁都无所谓; * 暂存目录,源数据全部抓到手;Windows 上用 DPAPI 解出源 Cookie 密钥,
* Windows 上同时把源 os_crypt 密钥(DPAPI 包裹)移植进本应用 Local State。 * 自行解密暂存 Cookies 库得到明文 Cookie 列表。
* 2) apply 热路径(无需重启应用): * 2) 分区轮换:受管分区一旦被本应用 Chromium 打开过,其 leveldb / Cookies 文件
* - Local Storage:文件迁移;分区被本应用 Chromium 锁住时执行「内核热重启」 * 句柄在本进程内无法释放(clearStorageData 只清数据不关句柄),对存活分区
* (销毁全部标签 + clearStorageData 释放存储句柄)后重试,随后自动恢复标签; * 做文件热替换必然 EPERM。因此导入时切换到「从未打开过」的下一代分区目录
* - Cookies:用 DPAPI 解出的密钥自行解密源 Cookies 数据库,逐条经 * (aso-browser-g<n>)——零句柄零锁,文件直接拷入即生效,搁置/重启不再存在。
* session.cookies.set 热注入(文件替换在进程存活期间永远不可行—— * 3) Cookie 走会话 API:受管浏览器自身登录态先从旧会话导出(cookies.get 返回
* CookieManager 与内存 CookieJar 都会覆盖它)。 * 明文)再写回新分区,源 Cookie 解密后逐条注入。全程不碰分区文件。
* 3) 兜底:Cookie 无法解密(非 Windows / app-bound V20 / DPAPI 失败)时, * 4) 旧分区目录在下次启动早期清理(那时无人持有句柄,见 cleanupStaleBrowserPartitions)。
* Cookies 文件转待办任务(pending),下次启动早期自动落盘生效。
*/ */
/** Chromium 系浏览器的用户数据根目录(按平台) */ /** Chromium 系浏览器的用户数据根目录(按平台) */
@@ -60,18 +59,16 @@ const SOURCE_ROOTS: Array<{ id: string; name: string; win: string; mac: string;
} }
] ]
/** 受管浏览器分区目录(persist:aso-browser 的落盘位置) */ /** 所有受管分区目录的根(userData/Partitions) */
function partitionDir(): string { function partitionsRoot(): string {
return join(app.getPath('userData'), 'Partitions', 'aso-browser') return join(app.getPath('userData'), 'Partitions')
} }
/** 待办导入目录(分区被占用时的暂存转正;启动早期自动应用) */ /** 指定分区名的磁盘目录 */
function pendingDir(): string { function partitionDirOf(name: string): string {
return join(app.getPath('userData'), 'Partitions', 'aso-browser.pending-import') return join(partitionsRoot(), name)
} }
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 ''
@@ -141,15 +138,15 @@ function copyLevelDbDir(from: string, to: string): boolean {
interface SourceKeyInfo { interface SourceKeyInfo {
key: string | null key: string | null
/** app-bound(V20)等不可移植场景的说明 */ /** 不可解密场景(非 Windows / app-bound V20 / 缺失)的说明 */
note?: string note?: string
} }
/** /**
* 读取源浏览器 Local State 里的 os_crypt.encrypted_key 并判断可移植性。 * 读取源浏览器 Local State 里的 os_crypt.encrypted_key 并判断可用性。
* Windows:前缀 DPAPI 可移植(文件兜底路径靠移植它;注入路径用它解密); * Windows:DPAPI 前缀可解(PowerShell CryptUnprotectData,用户级);
* V20 为 app-bound 加密(Chrome/Edge 127+),明确降级。 * V20 为 app-bound 加密(Chrome/Edge 127+),第三方无法解密,明确降级。
* macOS/Linux:密钥在系统钥匙串/KWallet,Cookies 无法解密,仅迁移 Local Storage。 * macOS/Linux:密钥在系统钥匙串/KWallet,仅迁移 Local Storage。
*/ */
function readSourceKey(userDataDir: string): SourceKeyInfo { function readSourceKey(userDataDir: string): SourceKeyInfo {
if (process.platform !== 'win32') { if (process.platform !== 'win32') {
@@ -163,7 +160,7 @@ function readSourceKey(userDataDir: string): SourceKeyInfo {
if (!encoded) return { key: null, note: '未找到源浏览器 Cookie 密钥,跳过 Cookie 迁移。' } if (!encoded) return { key: null, note: '未找到源浏览器 Cookie 密钥,跳过 Cookie 迁移。' }
const bytes = Buffer.from(encoded, 'base64') const bytes = Buffer.from(encoded, 'base64')
if (bytes.subarray(0, 3).toString('utf8') === 'V20') { 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') { if (bytes.subarray(0, 5).toString('utf8') !== 'DPAPI') {
return { key: null, note: '源浏览器 Cookie 密钥格式不受支持,跳过 Cookie 迁移。' } 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') return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8')
} }
/** 把源密钥移植进本应用根 Local State(首次会备份原文件;Cookie 文件兜底路径需要它) */ /** 源 Cookies 库 samesite 列值 → Electron 语义(-1 未指定 / 0 不限制 / 1 lax / 2 strict) */
function transplantKey(sourceKey: string): void { function mapSameSite(value: number | null | undefined): BrowserCookieSeed['sameSite'] {
const appLocalState = join(app.getPath('userData'), 'Local State') if (value === 0) return 'no_restriction'
const backup = `${appLocalState}.aso-backup` if (value === 1) return 'lax'
let doc: Record<string, unknown> = {} if (value === 2) return 'strict'
if (existsSync(appLocalState)) { return undefined
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')
} }
interface CollectedImport { interface CollectedImport {
/** 暂存目录里已有 Cookies 数据库文件(可作解密源) */
cookies: boolean cookies: boolean
localStorage: boolean localStorage: boolean
cookieNote?: string cookieNote?: string
/** 已移植源密钥(Cookie 文件兜底路径在重启后由本应用 Chromium 解密需要它) */
transplanted: boolean
} }
/** /**
* collect:源 profile → 暂存目录(分区布局:Local Storage/leveldb + Network/Cookies)。 * collect:源 profile → 暂存目录(Local Storage/leveldb + Network/Cookies)。
* 源文件被占用时抛出可操作的提示;Cookie 失败不阻塞(降级只导 Local Storage)。 * 源文件被占用时抛出可操作的提示;Cookie 拷贝失败不阻塞(降级只导 Local Storage)。
*/ */
function collectFromSource(root: string, sourceProfile: string, staging: string): CollectedImport { function collectFromSource(root: string, sourceProfile: string, staging: string): CollectedImport {
let localStorage = false let localStorage = false
@@ -258,7 +243,6 @@ function collectFromSource(root: string, sourceProfile: string, staging: string)
let cookies = false let cookies = false
let cookieNote: string | undefined let cookieNote: string | undefined
let transplanted = false
const keyInfo = readSourceKey(root) const keyInfo = readSourceKey(root)
const cookieDb = existsSync(join(sourceProfile, 'Network', 'Cookies')) const cookieDb = existsSync(join(sourceProfile, 'Network', 'Cookies'))
? join(sourceProfile, 'Network', 'Cookies') ? join(sourceProfile, 'Network', 'Cookies')
@@ -267,8 +251,6 @@ function collectFromSource(root: string, sourceProfile: string, staging: string)
: null : null
if (keyInfo.key && cookieDb) { if (keyInfo.key && cookieDb) {
try { try {
transplantKey(keyInfo.key)
transplanted = true
mkdirSync(join(staging, 'Network'), { recursive: true }) mkdirSync(join(staging, 'Network'), { recursive: true })
copyFile(cookieDb, join(staging, 'Network', 'Cookies')) copyFile(cookieDb, join(staging, 'Network', 'Cookies'))
for (const suffix of ['-wal', '-shm']) { for (const suffix of ['-wal', '-shm']) {
@@ -286,53 +268,42 @@ function collectFromSource(root: string, sourceProfile: string, staging: string)
cookieNote = keyInfo.note ?? '源浏览器没有可迁移的 Cookie 数据库。' cookieNote = keyInfo.note ?? '源浏览器没有可迁移的 Cookie 数据库。'
} }
return { cookies, localStorage, cookieNote, transplanted } return { cookies, localStorage, cookieNote }
} }
/** apply(LocalStorage):暂存 → 受管分区(分区被本应用 Chromium 锁住时抛原始 lock 错误) */ /** apply(LocalStorage):暂存 → 指定分区目录(目标应是未被 Chromium 打开过的目录) */
function applyStagedLocalStorage(staging: string): void { function applyStagedLocalStorage(staging: string, targetPartitionDir: string): void {
copyLevelDbDir(join(staging, 'Local Storage', 'leveldb'), join(partitionDir(), 'Local Storage', 'leveldb')) copyLevelDbDir(join(staging, 'Local Storage', 'leveldb'), join(targetPartitionDir, 'Local Storage', 'leveldb'))
} }
/** apply(Cookies 文件兜底):仅密钥已移植时有效(重启后 Chromium 用移植密钥解密) */ type SourceCookieRead =
function applyStagedCookies(staging: string): void { | { kind: 'ok'; cookies: BrowserCookieSeed[]; skipped: number }
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 }
| { kind: 'unsupported'; note: string } | { kind: 'unsupported'; note: string }
/** /**
* Cookie 热注入:解密暂存 Cookies 数据库并逐条写入分区会话(不碰文件,无需重启)。 * 解密暂存的源 Cookies 数据库 → 明文 Cookie 种子列表(之后经会话 API 注入,
* 返回 unsupported 表示当前环境无法解密(非 Windows / 无密钥 / DPAPI 失败)。 * 不碰分区文件,天然无锁)。返回 unsupported 表示当前环境无法解密。
*/ */
async function injectCookiesFromStaging(root: string, staging: string): Promise<CookieInjectResult> { async function readSourceCookies(root: string, staging: string): Promise<SourceCookieRead> {
if (process.platform !== 'win32') { if (process.platform !== 'win32') {
return { kind: 'unsupported', note: '当前平台 Cookie 密钥绑定系统钥匙串,Cookies 将以文件方式在重启后自动落盘。' } return { kind: 'unsupported', note: '当前平台的浏览器 Cookie 密钥绑定系统钥匙串,暂不支持迁移 Cookie(LocalStorage 登录态仍会导入)。' }
} }
const keyInfo = readSourceKey(root) 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) const key = await dpapiUnprotectKey(keyInfo.key)
if (!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 { try {
const rows = db 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() .all()
const controller = getBrowserController() const cookies: BrowserCookieSeed[] = []
let injected = 0 let skipped = 0
let failed = 0
for (const row of rows) { for (const row of rows) {
try { try {
const r = row as { const r = row as {
@@ -344,67 +315,52 @@ async function injectCookiesFromStaging(root: string, staging: string): Promise<
is_secure: number is_secure: number
is_httponly: number is_httponly: number
expires_utc: number expires_utc: number
samesite: number | null
} }
let value = r.value let value = r.value
if (!value && r.encrypted_value && r.encrypted_value.length > 3) { if (!value && r.encrypted_value && r.encrypted_value.length > 3) {
value = decryptChromeBlob(r.encrypted_value, key) value = decryptChromeBlob(r.encrypted_value, key)
} }
if (!value) { if (!value) {
failed++ skipped++
continue continue
} }
const secure = r.is_secure === 1 cookies.push({
const details: Electron.CookiesSetDetails = {
url: `${secure ? 'https' : 'http'}://${r.host_key.replace(/^\./, '')}${r.path || '/'}`,
name: r.name, name: r.name,
value, value,
domain: r.host_key, domain: r.host_key,
hostOnly: !r.host_key.startsWith('.'),
path: r.path || '/', path: r.path || '/',
secure, secure: r.is_secure === 1,
httpOnly: r.is_httponly === 1 httpOnly: r.is_httponly === 1,
} ...(r.expires_utc > 0 ? { expirationDate: r.expires_utc / 1_000_000 - 11_644_473_600 } : {}),
if (r.expires_utc > 0) details.expirationDate = r.expires_utc / 1_000_000 - 11_644_473_600 ...(mapSameSite(r.samesite) ? { sameSite: mapSameSite(r.samesite) } : {})
await controller.setCookie(details) })
injected++
} catch { } catch {
failed++ skipped++
} }
} }
return { kind: 'ok', injected, failed } return { kind: 'ok', cookies, skipped }
} finally { } finally {
db.close() db.close()
} }
} }
/** /** 导入互斥:同一时刻只允许一个导入在跑(分区轮换不是并发安全的) */
* 应用启动早期调用(分区尚未被 Chromium 打开):完成上次 Cookie 文件兜底 let importInFlight = false
* 路径搁置的待办导入。失败则保留待办,下次启动重试。
*/ /** 导入指定浏览器的 profile 配置到受管浏览器分区(无感热应用:无需重启应用,无搁置路径) */
export function applyPendingProfileImport(): void { export async function importBrowserProfile(sourceId: string, profileDir: string): Promise<BrowserImportResult> {
const pending = pendingDir() if (importInFlight) throw new Error('已有导入正在进行,请稍候。')
const metaFile = join(pending, IMPORT_META_FILE) importInFlight = true
if (!existsSync(metaFile)) return
try { try {
const meta = JSON.parse(readFileSync(metaFile, 'utf8')) as BrowserImportInfo return await importBrowserProfileInner(sourceId, profileDir)
applyStagedLocalStorage(pending) } finally {
applyStagedCookies(pending) importInFlight = false
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 配置到受管浏览器分区(热应用:通常无需重启应用) */ async function importBrowserProfileInner(sourceId: string, profileDir: string): Promise<BrowserImportResult> {
export async function importBrowserProfile(sourceId: string, profileDir: string): Promise<BrowserImportResult> {
const browser = getBrowserController() const browser = getBrowserController()
const root = sourceRoot(sourceId) const root = sourceRoot(sourceId)
if (!root || !existsSync(root)) throw new Error('未找到该浏览器的用户数据目录。') 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 sourceName = SOURCE_ROOTS.find((s) => s.id === sourceId)?.name ?? sourceId
const profileName = readProfileName(sourceProfile) const profileName = readProfileName(sourceProfile)
// 1) collect:源 → 暂存目录(源数据先抓到手;Cookies 文件作为解密源与兜底) // 1) collect + 源 Cookie 解密(源数据全部先抓到手,之后源浏览器锁不锁都无所谓)
const staging = join(app.getPath('userData'), 'Partitions', `.import-staging-${Date.now()}`) const staging = join(partitionsRoot(), `.import-staging-${Date.now()}`)
let collected: CollectedImport let collected: CollectedImport
try { try {
mkdirSync(staging, { recursive: true }) mkdirSync(staging, { recursive: true })
@@ -427,144 +383,98 @@ export async function importBrowserProfile(sourceId: string, profileDir: string)
rmSync(staging, { recursive: true, force: true }) rmSync(staging, { recursive: true, force: true })
throw err throw err
} }
const sourceRead: SourceCookieRead = collected.cookieNote
? { kind: 'unsupported', note: collected.cookieNote }
: await readSourceCookies(root, staging)
// 2) Local Storage 热应用:被分区占用时执行内核热重启(销毁标签 + 释放存储句柄) // 2) 受管浏览器自身的登录态快照(随分区轮换带过去;会话 API 返回明文)
let restarted = false const carried = await browser.exportCookies()
try {
applyStagedLocalStorage(staging) // 3) 关闭标签(导入完成后按 URL 恢复)
} catch (err) { const urls = browser.closeForImport()
if (!isLockError(err)) {
rmSync(staging, { recursive: true, force: true }) // 4) 分区轮换 + Local Storage 文件迁移:当前分区已有 Local Storage 落盘数据
throw new Error(`导入写入失败: ${err instanceof Error ? err.message : String(err)}`) // 说明其句柄可能被本应用 Chromium 持有,直接轮换到全新目录(零句柄零锁)
if (existsSync(join(partitionDirOf(browser.partitionName()), 'Local Storage'))) {
browser.rotatePartition()
} }
const urls = await browser.restartKernel()
restarted = true
try { try {
applyStagedLocalStorage(staging) applyStagedLocalStorage(staging, partitionDirOf(browser.partitionName()))
} catch (err2) { } catch (err) {
// 理论不可达(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) 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 }) rmSync(staging, { recursive: true, force: true })
throw new Error(`导入写入失败: ${err instanceof Error ? err.message : String(err)}`) throw new Error(`导入写入失败: ${err instanceof Error ? err.message : String(err)}`)
} }
const deferredNote = 'LocalStorage 已生效;点击「重启并完成导入」补上 Cookie。'
mkdirSync(join(app.getPath('userData'), 'Partitions'), { recursive: true }) // 5) Cookie:先回写受管浏览器原有登录态,再注入源 Cookie(全程会话 API,不碰文件)
rmSync(pendingDir(), { recursive: true, force: true }) const carry = await browser.importCookies(carried)
writeFileSync( let injected = 0
join(staging, IMPORT_META_FILE), let cookieNote: string | undefined
JSON.stringify( if (sourceRead.kind === 'ok') {
{ browser: sourceName, profile: profileName, at: new Date().toISOString(), cookies: true, localStorage: true, note: deferredNote }, const result = await browser.importCookies(sourceRead.cookies)
null, injected = result.imported
2 const skipped = result.failed + sourceRead.skipped
), if (skipped > 0) cookieNote = `源 Cookie ${injected} 条已注入,${skipped} 条无法解密已跳过。`
'utf8' } else {
) cookieNote = sourceRead.note
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 }
} }
if (!cookieNote && carry.failed > 0) {
cookieNote = `受管浏览器原有 Cookie ${carry.imported} 条已保留,${carry.failed} 条写回失败。`
}
// 6) 收尾:恢复标签、清理暂存、记录导入来源(供「已导入」展示与一键同步)
browser.restoreTabs(urls)
rmSync(staging, { recursive: true, force: true }) rmSync(staging, { recursive: true, force: true })
if (restarted) browser.restoreTabs([]) const info: BrowserImportInfo = {
browser.setImportedInfo({ sourceId,
browser: sourceName, browser: sourceName,
profile: profileName, profile: profileName,
profileDir: dirName,
at: new Date().toISOString(), at: new Date().toISOString(),
cookies: true, cookies: injected > 0,
localStorage: collected.localStorage, localStorage: collected.localStorage,
note: collected.cookieNote ...(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 { return {
ok: true, ok: true,
cookies: true, cookies: injected > 0,
localStorage: collected.localStorage, localStorage: collected.localStorage,
cookieNote: collected.cookieNote, ...(cookieNote ? { cookieNote } : {}),
message: `已从 ${sourceName} 导入 Cookie + LocalStorage${restarted ? '(浏览器内核已热重启)' : ''}。若登录态未生效,重启应用后重试。` message: `已从 ${sourceName}「${profileName}」导入:${summary}。${urls.length > 0 ? '原标签页已自动恢复。' : ''}${cookieNote ?? ''}`
} }
} }
rmSync(staging, { recursive: true, force: true }) /**
if (restarted) browser.restoreTabs([]) * 启动早期调用(分区尚未被任何页面打开):清理上一代分区目录与残留暂存。
* 失败只记日志(旧目录多留一启动无害,下次再试)。
if (inject.kind === 'ok') { */
const cookies = inject.injected > 0 export async function cleanupStaleBrowserPartitions(): Promise<void> {
const cookieNote = const root = partitionsRoot()
inject.failed > 0 ? `Cookie ${inject.injected} 条注入成功,${inject.failed} 条解密失败已跳过。` : undefined if (!existsSync(root)) return
browser.setImportedInfo({ const keep = getBrowserController().partitionName()
browser: sourceName, const stale: string[] = []
profile: profileName, for (const entry of readdirSync(root, { withFileTypes: true })) {
at: new Date().toISOString(), if (!entry.isDirectory()) continue
cookies, if (entry.name === keep) continue
localStorage: true, if (entry.name.startsWith('.import-staging-') || entry.name === 'aso-browser.pending-import' || /^aso-browser(-g\d+)?$/.test(entry.name)) {
note: cookieNote stale.push(entry.name)
})
return {
ok: true,
cookies,
localStorage: true,
cookieNote,
message: `已从 ${sourceName} 热导入 LocalStorage + Cookie ${inject.injected} 条${
inject.failed > 0 ? `(${inject.failed} 条跳过)` : ''
}(浏览器内核已热重启,无需重启应用)。`
} }
} }
if (stale.length === 0) return
// 无密钥/不支持:只有 LocalStorage 生效 for (const name of stale) {
browser.setImportedInfo({ try {
browser: sourceName, await rmAsync(join(root, name), { recursive: true, force: true, maxRetries: 1 })
profile: profileName, } catch (err) {
at: new Date().toISOString(), log.warn('清理旧分区目录失败,下次启动重试', { name, err: err instanceof Error ? err.message : String(err) })
cookies: false,
localStorage: true,
note: inject.note
})
return {
ok: true,
cookies: false,
localStorage: true,
cookieNote: inject.note,
message: `已从 ${sourceName} 导入 LocalStorage。${inject.note ?? ''}`
} }
} }
log.info('cleaned stale browser partitions', { stale })
}
+3 -9
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 { applyPendingProfileImport, detectBrowserSources, importBrowserProfile } from './browser/browser-profile' import { cleanupStaleBrowserPartitions, detectBrowserSources, importBrowserProfile } from './browser/browser-profile'
import { getLogger } from './logger' import { getLogger } from './logger'
const log = getLogger('ipc') const log = getLogger('ipc')
@@ -274,12 +274,6 @@ export function registerIpc(): AgentService {
return Promise.resolve() return Promise.resolve()
}) })
// 应用自重启(浏览器数据导入被搁置时的一键完成入口)
handle('app:relaunch', () => {
app.relaunch()
app.exit(0)
})
handle('app:versions', () => ({ handle('app:versions', () => ({
app: process.env.npm_package_version ?? '0.0.0', app: process.env.npm_package_version ?? '0.0.0',
electron: process.versions.electron, electron: process.versions.electron,
@@ -428,8 +422,8 @@ export function registerIpc(): AgentService {
// ---- 插件系统 ---- // ---- 插件系统 ----
void app.whenReady().then(() => { void app.whenReady().then(() => {
// 上次搁置的浏览器数据导入在此刻应用(分区尚未被 Chromium 打开,文件必然可写) // 清理上一代受管分区与残留暂存(此刻无人持有句柄;导入已无搁置路径)
applyPendingProfileImport() void cleanupStaleBrowserPartitions()
// 插件启停/重载会重建 agent 运行时(extensionFactories 重跑) // 插件启停/重载会重建 agent 运行时(extensionFactories 重跑)
getPluginHost().setRuntimeRebuilder(() => service.rebuildRuntime()) getPluginHost().setRuntimeRebuilder(() => service.rebuildRuntime())
}) })
-1
View File
@@ -55,7 +55,6 @@ const api = {
getState: (): Promise<SessionState> => ipcRenderer.invoke('agent:state'), getState: (): Promise<SessionState> => ipcRenderer.invoke('agent:state'),
versions: (): Promise<{ app: string; electron: string; node: string; chrome: string; user: string }> => versions: (): Promise<{ app: string; electron: string; node: string; chrome: string; user: string }> =>
ipcRenderer.invoke('app:versions'), ipcRenderer.invoke('app:versions'),
appRelaunch: (): Promise<void> => ipcRenderer.invoke('app:relaunch'),
listSessions: (): Promise<SessionMeta[]> => ipcRenderer.invoke('sessions:list'), listSessions: (): Promise<SessionMeta[]> => ipcRenderer.invoke('sessions:list'),
newSession: (): Promise<void> => ipcRenderer.invoke('sessions:new'), newSession: (): Promise<void> => ipcRenderer.invoke('sessions:new'),
openSession: (path: string): Promise<void> => ipcRenderer.invoke('sessions:open', path), openSession: (path: string): Promise<void> => ipcRenderer.invoke('sessions:open', path),
@@ -2,12 +2,20 @@
* 本机浏览器登录态导入表单(面板浮层与设置页二级页共用)。 * 本机浏览器登录态导入表单(面板浮层与设置页二级页共用)。
* 选择本机浏览器(Chrome/Edge/Brave/Chromium)的一个 Profile, * 选择本机浏览器(Chrome/Edge/Brave/Chromium)的一个 Profile,
* 把各站点登录态(Cookie / Local Storage)迁移到受管浏览器分区。 * 把各站点登录态(Cookie / Local Storage)迁移到受管浏览器分区。
* 导入全程无感:无需重启应用;已导入的来源常驻展示,可一键「同步」复导。
* 自含数据源:直接调用 window.api(browserDetectSources / browserImportProfile)。 * 自含数据源:直接调用 window.api(browserDetectSources / browserImportProfile)。
*/ */
import { useEffect, useState, type JSX } from 'react' import { useEffect, useState, type JSX } from 'react'
import { LuChevronDown } from 'react-icons/lu' import { LuChevronDown, LuRefreshCw } from 'react-icons/lu'
import type { BrowserImportResult, BrowserSource, BrowserUiState } from '@shared/types' import type { BrowserImportResult, BrowserSource, BrowserUiState } from '@shared/types'
function formatAt(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
const pad = (n: number): string => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
export function BrowserImportForm(): JSX.Element { export function BrowserImportForm(): JSX.Element {
const [state, setState] = useState<BrowserUiState | null>(null) const [state, setState] = useState<BrowserUiState | null>(null)
const [sources, setSources] = useState<BrowserSource[] | null>(null) const [sources, setSources] = useState<BrowserSource[] | null>(null)
@@ -51,29 +59,53 @@ export function BrowserImportForm(): JSX.Element {
} }
}, []) }, [])
const runImport = (): void => { const runImport = (target: { sourceId: string; profileDir: string } | null): void => {
if (!selected) return if (!target || busy) return
setBusy(true) setBusy(true)
setError(null) setError(null)
setResult(null) setResult(null)
window.api window.api
.browserImportProfile(selected.sourceId, selected.profileDir) .browserImportProfile(target.sourceId, target.profileDir)
.then(setResult) .then(setResult)
.catch((err) => setError(err instanceof Error ? err.message : String(err))) .catch((err) => setError(err instanceof Error ? err.message : String(err)))
.finally(() => setBusy(false)) .finally(() => setBusy(false))
} }
const imported = state?.imported ?? null
return ( return (
<div className="text-[12px] text-ink-2"> <div className="text-[12px] text-ink-2">
<p className="mb-3 leading-relaxed text-ink-3"> <p className="mb-3 leading-relaxed text-ink-3">
迁移 Cookie / Local Storage 登录态。导入前请 迁移 Cookie / Local Storage 登录态,导入后立即生效。导入前请
<span className="font-medium text-ink-2">完全退出</span>源浏览器,并关闭本面板所有标签页。 <span className="font-medium text-ink-2">完全退出</span>源浏览器(含后台进程)。
</p> </p>
{state?.imported && ( {imported && (
<div className="mb-3 rounded border border-line bg-inset px-2.5 py-2 text-[11.5px] text-ink-3"> <div className="mb-3 rounded border border-line bg-inset px-2.5 py-2">
当前导入来源:{state.imported.browser} · {state.imported.profile} <div className="flex items-center gap-2">
{state.imported.cookies ? '(含 Cookie)' : ''} <span className="shrink-0 text-[11px] text-ink-3">已导入</span>
<span className="min-w-0 flex-1 truncate text-[12px] text-ink">
{imported.browser} · {imported.profile}
{imported.profileDir && <span className="ml-1.5 text-[10.5px] text-ink-3">{imported.profileDir}</span>}
</span>
{imported.sourceId && imported.profileDir && (
<button
type="button"
disabled={busy}
onClick={() => runImport({ sourceId: imported.sourceId!, profileDir: imported.profileDir! })}
className="flex shrink-0 items-center gap-1 rounded border border-line-strong px-2 py-0.5 text-[11px] text-ink-2 transition-colors hover:bg-hover hover:text-ink disabled:opacity-40"
>
<LuRefreshCw size={11} className={busy ? 'animate-spin' : undefined} />
同步
</button>
)}
</div>
<div className="mt-1 text-[10.5px] text-ink-3">
{formatAt(imported.at)}
{imported.cookieCount ? ` · Cookie ${imported.cookieCount} 条` : imported.cookies ? ' · 含 Cookie' : ''}
{imported.localStorage ? ' · LocalStorage' : ''}
</div>
{imported.note && <div className="mt-1 text-[10.5px] leading-relaxed text-ink-3">{imported.note}</div>}
</div> </div>
)} )}
@@ -128,26 +160,12 @@ export function BrowserImportForm(): JSX.Element {
</div> </div>
)} )}
{/* 操作区:始终集中在底部一行;被搁置时以「重启并完成导入」为主操作 */} <div className="mt-3 flex items-center justify-end">
<div className="mt-3 flex items-center justify-end gap-2">
{result?.deferred && (
<button
type="button"
onClick={() => void window.api.appRelaunch()}
className="rounded bg-accent px-3.5 py-1.5 text-[12px] font-medium text-page transition-opacity hover:opacity-90"
>
重启并完成导入
</button>
)}
<button <button
type="button" type="button"
disabled={!selected || busy} disabled={!selected || busy}
onClick={runImport} onClick={() => runImport(selected)}
className={`rounded px-3.5 py-1.5 text-[12px] transition-colors 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"
result?.deferred
? 'border border-line-strong text-ink-2 hover:bg-hover hover:text-ink'
: 'bg-accent font-medium text-page hover:opacity-90'
}`}
> >
{busy ? '导入中…' : '导入浏览器数据'} {busy ? '导入中…' : '导入浏览器数据'}
</button> </button>
-1
View File
@@ -58,7 +58,6 @@ declare global {
stop: () => Promise<void> stop: () => Promise<void>
getState: () => Promise<SessionState> getState: () => Promise<SessionState>
versions: () => Promise<{ app: string; electron: string; node: string; chrome: string; user: string }> versions: () => Promise<{ app: string; electron: string; node: string; chrome: string; user: string }>
appRelaunch: () => Promise<void>
listSessions: () => Promise<SessionMeta[]> listSessions: () => Promise<SessionMeta[]>
newSession: () => Promise<void> newSession: () => Promise<void>
openSession: (path: string) => Promise<void> openSession: (path: string) => Promise<void>
+5 -4
View File
@@ -271,13 +271,16 @@ export interface BrowserClosedTab {
url: string url: string
} }
/** 已导入的本地浏览器配置摘要(面板展示用) */ /** 已导入的本地浏览器配置摘要(面板/设置页展示用;sourceId + profileDir 供「同步」一键复导) */
export interface BrowserImportInfo { export interface BrowserImportInfo {
sourceId: string
browser: string browser: string
profile: string profile: string
profileDir: string
at: string at: string
cookies: boolean cookies: boolean
localStorage: boolean localStorage: boolean
cookieCount?: number
note?: string note?: string
} }
@@ -326,15 +329,13 @@ export interface BrowserSource {
profiles: BrowserProfileMeta[] profiles: BrowserProfileMeta[]
} }
/** 导入结果(主进程返回;cookieNote 说明跳过原因) */ /** 导入结果(主进程返回;全程无感,无需重启应用;cookieNote 说明跳过原因) */
export interface BrowserImportResult { export interface BrowserImportResult {
ok: boolean ok: boolean
cookies: boolean cookies: boolean
localStorage: boolean localStorage: boolean
cookieNote?: string cookieNote?: string
message: string message: string
/** true = 分区被占用,已转待办:重启应用后自动完成导入 */
deferred?: boolean
} }
export type AgentUiEvent = export type AgentUiEvent =