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