diff --git a/apps/desktop/resources/cookie-exporter/README.md b/apps/desktop/resources/cookie-exporter/README.md deleted file mode 100644 index d31e068..0000000 --- a/apps/desktop/resources/cookie-exporter/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# ASO Cookie 导出助手 - -Chrome 127+ 对 Cookie 启用了应用绑定加密(app-bound encryption),浏览器外的程序(包括本应用)无法解密 Cookie 文件。Chrome 官方留给扩展的 `chrome.cookies` API 仍返回明文——这个扩展就是走这条通道,把 Cookie 导出成备份文件,再到 Inkreach Agent 里导入。 - -## 使用步骤(一次性) - -1. 打开 Chrome,地址栏输入 `chrome://extensions/` 回车。 -2. 右上角打开「开发者模式」。 -3. 点「加载已解压的扩展程序」,选择本目录(`cookie-exporter` 文件夹)。 -4. 点浏览器工具栏里的扩展图标(拼图形状)→ 找到「ASO Cookie 导出助手」→ 点「导出全部 Cookie」,选择保存位置。 -5. 回到 Inkreach Agent:设置 → 浏览器 → 「Cookie 备份导入」→ 选择刚保存的 JSON 文件,立即生效。 - -Edge / Brave 等 Chromium 系浏览器同样适用(各自扩展页加载本目录即可)。 - -> 导出的备份文件包含明文登录凭据,用完建议删除,不要发给别人。 diff --git a/apps/desktop/resources/cookie-exporter/manifest.json b/apps/desktop/resources/cookie-exporter/manifest.json deleted file mode 100644 index 6ed129b..0000000 --- a/apps/desktop/resources/cookie-exporter/manifest.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "manifest_version": 3, - "name": "ASO Cookie 导出助手", - "version": "1.0.0", - "description": "把本浏览器的全部 Cookie 导出为 JSON 备份,供 Inkreach Agent 受管浏览器导入(Chrome 应用绑定加密下的官方通道)。", - "permissions": ["cookies", "downloads"], - "host_permissions": [""], - "action": { - "default_popup": "popup.html" - } -} diff --git a/apps/desktop/resources/cookie-exporter/popup.html b/apps/desktop/resources/cookie-exporter/popup.html deleted file mode 100644 index 4bd9130..0000000 --- a/apps/desktop/resources/cookie-exporter/popup.html +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - ASO Cookie 导出助手 - - - -

Cookie 导出助手

-

导出本浏览器全部 Cookie(明文),到 Inkreach Agent 的 设置 → 浏览器 → Cookie 备份导入 里使用。

- -
- - - diff --git a/apps/desktop/resources/cookie-exporter/popup.js b/apps/desktop/resources/cookie-exporter/popup.js deleted file mode 100644 index 6222f33..0000000 --- a/apps/desktop/resources/cookie-exporter/popup.js +++ /dev/null @@ -1,47 +0,0 @@ -// 导出全部 Cookie 为 JSON 备份(chrome.cookies API 返回明文——Chrome 官方提供给扩展的通道) -const statusEl = document.getElementById('status') -const button = document.getElementById('export') - -function setStatus(text) { - statusEl.textContent = text -} - -button.addEventListener('click', async () => { - button.disabled = true - try { - setStatus('正在读取 Cookie…') - const cookies = await chrome.cookies.getAll({}) - if (!cookies || cookies.length === 0) { - setStatus('未读取到任何 Cookie。') - return - } - const payload = { - format: 'aso-cookie-export', - version: 1, - exportedAt: new Date().toISOString(), - cookies: cookies.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 } : {}) - })) - } - const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' }) - const url = URL.createObjectURL(blob) - const d = new Date() - const pad = (n) => String(n).padStart(2, '0') - const filename = `aso-cookies-${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}.json` - await chrome.downloads.download({ url, filename, saveAs: true }) - URL.revokeObjectURL(url) - setStatus(`已导出 ${payload.cookies.length} 条 Cookie。回到 Inkreach Agent:设置 → 浏览器 → Cookie 备份导入,选择刚保存的文件。`) - } catch (err) { - setStatus('导出失败:' + (err && err.message ? err.message : String(err))) - } finally { - button.disabled = false - } -}) diff --git a/apps/desktop/src/main/browser/browser-controller.ts b/apps/desktop/src/main/browser/browser-controller.ts index 2bccb11..5c4ed3a 100644 --- a/apps/desktop/src/main/browser/browser-controller.ts +++ b/apps/desktop/src/main/browser/browser-controller.ts @@ -2,9 +2,10 @@ import { getLogger } from '../logger' const log = getLogger('browser') import { app, BrowserWindow, session as electronSession, WebContentsView, type Rectangle, type Session } from 'electron' -import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' +import { rm as rmAsync } from 'node:fs/promises' import { join } from 'node:path' -import type { BrowserImportInfo, BrowserProxySettings, BrowserTabInfo, BrowserUiState } from '@shared/types' +import type { BrowserProxySettings, BrowserTabInfo, BrowserUiState } from '@shared/types' import { BrowserCdpTimeoutError, BROWSER_OBSERVE_TIMEOUT_MS, @@ -30,17 +31,13 @@ import { * 渲染层 BrowserSlot 量出占位矩形,经 IPC 同步边界(原生视图天然盖在 DOM 之上)。 * - 页面观察/操作全部走 CDP(webContents.debugger):AX tree 快照 + ref 代际机制 * + Input.dispatch* 真实输入事件;不在页面里执行模拟点击。 - * - 登录态隔离在受管分区;本地浏览器配置导入由 browser-profile.ts 完成: - * 分区轮换(aso-browser-g)+ Cookie 会话 API 热注入,全程无需重启应用。 + * - 登录态隔离在 persist:aso-browser 分区,随分区持久保存。 * - 代理设置作用于同一分区会话(session.setProxy):跟随系统 / 直连 / 手动规则 * (Chromium proxyRules 语法 + 绕过列表),持久化进 aso-browser.json, * 启动即应用、修改后断开在途连接立即生效(对 Agent 的 browser_* 工具同样生效)。 */ -// 受管分区名(persist: 前缀之外的磁盘目录名):gen 0 为初始名,导入时轮换 +1 -function partitionNameOf(gen: number): string { - return gen <= 0 ? 'aso-browser' : `aso-browser-g${gen}` -} +const PARTITION = 'persist:aso-browser' // 新标签页默认地址:google.com 在目标市场(国内)不可达,新开标签会长时间白屏, // 误以为浏览器故障;用可稳定访问的导航页 const NEW_TAB_URL = 'https://www.baidu.com/' @@ -76,25 +73,7 @@ interface TabRecord { 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 { @@ -345,16 +324,12 @@ export class BrowserController { constructor() { this.settings = this.loadSettings() this.browserSession = this.createBrowserSession() + this.cleanupLegacyImportArtifacts() } - /** 当前受管分区名(persist: 之外的磁盘目录名;导入文件迁移与启动清理按它定位) */ - partitionName(): string { - return partitionNameOf(this.settings.partitionGen ?? 0) - } - - /** 创建/重建受管分区会话并挂上固定策略(UA、权限、下载、代理) */ + /** 创建受管分区会话并挂上固定策略(UA、权限、下载、代理) */ private createBrowserSession(): Session { - const session = electronSession.fromPartition(`persist:${this.partitionName()}`) + const session = electronSession.fromPartition(PARTITION) // 诚实的 UA:保留 Chromium token 保证站点兼容,去掉 Electron 标识 session.setUserAgent(session.getUserAgent().replace(/Electron\/[\d.]+/, `InkreachAgent/${app.getVersion()}`)) // 网页权限请求(摄像头/定位等)默认拒绝 @@ -389,20 +364,15 @@ export class BrowserController { try { const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as Partial const proxy = raw.proxy - const imported = raw.imported - // 旧版「搁置/需重启」类提示是一次性状态说明,不该常驻展示——历史残留直接清掉 - if (imported?.note && /被占用|暂存|搁置|重启/.test(imported.note)) imported.note = undefined return { riskAcknowledged: raw.riskAcknowledged === true, - imported: imported ?? null, proxy: proxy && (proxy.mode === 'direct' || proxy.mode === 'fixed' || proxy.mode === 'system') ? proxy - : { mode: 'system' }, - partitionGen: typeof raw.partitionGen === 'number' && raw.partitionGen >= 0 ? Math.floor(raw.partitionGen) : 0 + : { mode: 'system' } } } catch { - return { riskAcknowledged: false, proxy: { mode: 'system' }, partitionGen: 0 } + return { riskAcknowledged: false, proxy: { mode: 'system' } } } } @@ -414,90 +384,34 @@ export class BrowserController { } } - /** 配置导入完成后由 browser-profile.ts 回写摘要 */ - setImportedInfo(info: BrowserImportInfo | null): void { - this.settings.imported = info - this.saveSettings() - this.emitState() - } - - get importedInfo(): BrowserImportInfo | null { - return this.settings.imported ?? null - } - - /** 分区轮换(导入无感应用的核心):切换到从未被打开过的下一代分区目录。 - * 旧分区的 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() - return urls - } - - /** 恢复导入前打开的标签 */ - restoreTabs(urls: string[]): void { - for (const url of urls) { - try { - this.newTab(url, false) - } catch (err) { - log.warn('restore tab failed', { url, err: err instanceof Error ? err.message : String(err) }) + /** 历史遗留清理:删除已下线的「登录态导入」功能留下的轮换分区目录与暂存 + * (aso-browser-g* / .import-staging-* / aso-browser.pending-import)。 + * 控制器创建时无人持有这些目录的句柄;失败只记日志,下次启动再试。 */ + private cleanupLegacyImportArtifacts(): void { + const root = join(app.getPath('userData'), 'Partitions') + if (!existsSync(root)) return + const stale: string[] = [] + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + if ( + entry.name.startsWith('.import-staging-') || + entry.name === 'aso-browser.pending-import' || + /^aso-browser-g\d+$/.test(entry.name) + ) { + stale.push(entry.name) } } - } - - /** 导出当前分区全部 Cookie(会话 API 返回明文;分区轮换前快照,导入后写回新分区) */ - async exportCookies(): Promise { - 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 } + if (stale.length === 0) return + void (async () => { + 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 legacy import artifacts', { stale }) + })() } // ---- 代理设置 ---- @@ -573,7 +487,6 @@ export class BrowserController { activeTabId: this.activeTabId, recentClosed: this.closedTabs.map(({ title, url }) => ({ title, url })), trace: this.traceText, - imported: this.settings.imported ?? null, proxy: this.proxySettings() } } @@ -635,14 +548,13 @@ 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()}` + // popup 的 viewOptions 不一定带 partition(会导致弹窗落入默认会话),统一强制受管分区 const view = new WebContentsView( viewOptions - ? { ...viewOptions, webPreferences: { ...(viewOptions.webPreferences ?? {}), partition } } + ? { ...viewOptions, webPreferences: { ...(viewOptions.webPreferences ?? {}), partition: PARTITION } } : { webPreferences: { - partition, + partition: PARTITION, nodeIntegration: false, contextIsolation: true, sandbox: true, @@ -794,7 +706,7 @@ export class BrowserController { return this.buildState() } - /** 关闭整个受管浏览器(销毁全部标签;导入配置前也会走这里) */ + /** 关闭整个受管浏览器(销毁全部标签;分区会话与登录态保留) */ close(): BrowserUiState { for (const tabId of [...this.tabs.keys()]) this.closeTab(tabId) this.agentTabId = null diff --git a/apps/desktop/src/main/browser/browser-profile.ts b/apps/desktop/src/main/browser/browser-profile.ts deleted file mode 100644 index 1b8375a..0000000 --- a/apps/desktop/src/main/browser/browser-profile.ts +++ /dev/null @@ -1,596 +0,0 @@ -import { app } from 'electron' -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, type BrowserCookieSeed } from './browser-controller' -import { getLogger } from '../logger' - -const log = getLogger('browser-profile') - -/** - * 本地浏览器配置导入(复用各网站登录态)——全程无感,无需重启应用: - * - * 1) collect:源 profile 的 Local Storage(明文 leveldb)与 Cookies 数据库先拷入 - * 暂存目录,源数据全部抓到手;Windows 上用 DPAPI 解出源 Cookie 密钥, - * 自行解密暂存 Cookies 库得到明文 Cookie 列表。 - * 2) 分区轮换:受管分区一旦被本应用 Chromium 打开过,其 leveldb / Cookies 文件 - * 句柄在本进程内无法释放(clearStorageData 只清数据不关句柄),对存活分区 - * 做文件热替换必然 EPERM。因此导入时切换到「从未打开过」的下一代分区目录 - * (aso-browser-g)——零句柄零锁,文件直接拷入即生效,搁置/重启不再存在。 - * 3) Cookie 走会话 API:受管浏览器自身登录态先从旧会话导出(cookies.get 返回 - * 明文)再写回新分区,源 Cookie 解密后逐条注入。全程不碰分区文件。 - * 4) 旧分区目录在下次启动早期清理(那时无人持有句柄,见 cleanupStaleBrowserPartitions)。 - */ - -/** Chromium 系浏览器的用户数据根目录(按平台) */ -const SOURCE_ROOTS: Array<{ id: string; name: string; win: string; mac: string; linux: string }> = [ - { - id: 'chrome', - name: 'Google Chrome', - win: join(process.env.LOCALAPPDATA ?? '', 'Google', 'Chrome', 'User Data'), - mac: join(homedir(), 'Library', 'Application Support', 'Google', 'Chrome'), - linux: join(homedir(), '.config', 'google-chrome') - }, - { - id: 'edge', - name: 'Microsoft Edge', - win: join(process.env.LOCALAPPDATA ?? '', 'Microsoft', 'Edge', 'User Data'), - mac: join(homedir(), 'Library', 'Application Support', 'Microsoft Edge'), - linux: join(homedir(), '.config', 'microsoft-edge') - }, - { - id: 'brave', - name: 'Brave', - win: join(process.env.LOCALAPPDATA ?? '', 'BraveSoftware', 'Brave-Browser', 'User Data'), - mac: join(homedir(), 'Library', 'Application Support', 'BraveSoftware', 'Brave-Browser'), - linux: join(homedir(), '.config', 'BraveSoftware', 'Brave-Browser') - }, - { - id: 'chromium', - name: 'Chromium', - win: join(process.env.LOCALAPPDATA ?? '', 'Chromium', 'User Data'), - mac: join(homedir(), 'Library', 'Application Support', 'Chromium'), - linux: join(homedir(), '.config', 'chromium') - } -] - -/** 所有受管分区目录的根(userData/Partitions) */ -function partitionsRoot(): string { - return join(app.getPath('userData'), 'Partitions') -} - -/** 指定分区名的磁盘目录 */ -function partitionDirOf(name: string): string { - return join(partitionsRoot(), name) -} - -function sourceRoot(id: string): string { - const source = SOURCE_ROOTS.find((s) => s.id === id) - if (!source) return '' - return process.platform === 'win32' ? source.win : process.platform === 'darwin' ? source.mac : source.linux -} - -/** 读取 profile 的展示名(Preferences 的 profile.name,失败时用目录名) */ -function readProfileName(profileDir: string): string { - try { - const file = join(profileDir, 'Preferences') - if (statSync(file).size > 8 * 1024 * 1024) return profileDir.split(/[\\/]/).pop() || profileDir - const raw = JSON.parse(readFileSync(file, 'utf8')) as { profile?: { name?: string } } - return raw.profile?.name?.trim() || profileDir.split(/[\\/]/).pop() || profileDir - } catch { - return profileDir.split(/[\\/]/).pop() || profileDir - } -} - -/** 检测本机可导入的浏览器与 profile 列表 */ -export function detectBrowserSources(): BrowserSource[] { - const sources: BrowserSource[] = [] - for (const source of SOURCE_ROOTS) { - const root = sourceRoot(source.id) - if (!root || !existsSync(root)) continue - const profiles: BrowserProfileMeta[] = [] - try { - for (const entry of readdirSync(root, { withFileTypes: true })) { - if (!entry.isDirectory()) continue - if (entry.name.startsWith('.') || entry.name === 'System Profile' || entry.name === 'Guest Profile') continue - const profileDir = join(root, entry.name) - if (!existsSync(join(profileDir, 'Preferences'))) continue - profiles.push({ dir: entry.name, name: readProfileName(profileDir) }) - } - } catch { - // 目录不可读则跳过该浏览器 - continue - } - if (profiles.length > 0) { - sources.push({ id: source.id, name: source.name, userDataDir: root, profiles }) - } - } - return sources -} - -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 运行时文件),目标已存在时先清空 */ -function copyLevelDbDir(from: string, to: string): boolean { - if (!existsSync(from)) return false - const entries = readdirSync(from, { withFileTypes: true }) - const files = entries.filter((e) => e.isFile() && e.name !== 'LOCK' && !e.name.startsWith('LOG')) - if (files.length === 0) return false - rmSync(to, { recursive: true, force: true }) - mkdirSync(to, { recursive: true }) - for (const file of files) { - copyFile(join(from, file.name), join(to, file.name)) - } - return true -} - -interface SourceKeyInfo { - key: string | null - /** 不可解密场景(非 Windows / app-bound V20 / 缺失)的说明 */ - note?: string -} - -/** - * 读取源浏览器 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') { - return { key: null, note: '当前平台的浏览器 Cookie 密钥绑定系统钥匙串,暂不支持迁移 Cookie(LocalStorage 登录态仍会导入)。' } - } - try { - const raw = JSON.parse(readFileSync(join(userDataDir, 'Local State'), 'utf8')) as { - os_crypt?: { encrypted_key?: string } - } - const encoded = raw.os_crypt?.encrypted_key - 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),Cookie 无法第三方迁移;LocalStorage 已导入,需登录的站点在受管浏览器里登录一次即可。' } - } - if (bytes.subarray(0, 5).toString('utf8') !== 'DPAPI') { - return { key: null, note: '源浏览器 Cookie 密钥格式不受支持,跳过 Cookie 迁移。' } - } - return { key: encoded } - } catch { - return { key: null, note: '读取源浏览器 Local State 失败,跳过 Cookie 迁移。' } - } -} - -/** DPAPI 解出源浏览器的 AES 密钥(PowerShell 调 CryptUnprotectData;用户级,无需管理员)。 - * Chromium 的 encrypted_key = base64("DPAPI" + 密文 blob):CryptUnprotectData 只接受 - * 剥掉 5 字节前缀后的 blob,前缀判断必须在解码后的字节上做——base64 文本本身 - * 永远不以 "DPAPI" 开头(实测带前缀调用必然 CryptographicException: 数据无效)。 */ -function dpapiUnprotectKey(encodedKey: string): Promise { - const bytes = Buffer.from(encodedKey, 'base64') - const blob = bytes.subarray(0, 5).toString('utf8') === 'DPAPI' ? bytes.subarray(5).toString('base64') : encodedKey - const script = - `$d=[Convert]::FromBase64String('${blob}');` + - 'Add-Type -AssemblyName System.Security;' + - '[Convert]::ToBase64String([System.Security.Cryptography.ProtectedData]::Unprotect($d,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser))' - return new Promise((resolve) => { - execFile( - 'powershell.exe', - ['-NoProfile', '-NonInteractive', '-Command', script], - { timeout: 20_000 }, - (err, stdout, stderr) => { - if (err || !stdout.trim()) { - log.warn('dpapi unprotect failed', { - err: err instanceof Error ? err.message : String(err ?? 'empty output'), - stderr: stderr ? String(stderr).slice(0, 200) : undefined - }) - resolve(null) - return - } - try { - resolve(Buffer.from(stdout.trim(), 'base64')) - } catch { - resolve(null) - } - } - ) - }) -} - -/** 解密单条 Chrome Cookie(v10/v11 均为 AES-256-GCM:3 字节版本 + 12 字节 nonce + 密文 + 16 字节 tag)。 - * 注意 node:sqlite 返回的 BLOB 是裸 Uint8Array 而非 Buffer——其 toString() 会忽略 - * 编码参数、返回逗号拼接的数字,必须先转成 Buffer 才能按 utf8 读版本头。 */ -function decryptChromeBlob(encrypted: Uint8Array, key: Buffer): string { - const blob = Buffer.isBuffer(encrypted) ? encrypted : Buffer.from(encrypted) - const version = blob.subarray(0, 3).toString('utf8') - if (version !== 'v10' && version !== 'v11') throw new Error(`不支持的 Cookie 加密版本: ${version}`) - const nonce = blob.subarray(3, 15) - const tag = blob.subarray(blob.length - 16) - const data = blob.subarray(15, blob.length - 16) - const decipher = createDecipheriv('aes-256-gcm', key, nonce) - decipher.setAuthTag(tag) - return Buffer.concat([decipher.update(data), decipher.final()]).toString('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 -} - -/** - * 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(staging, 'Local Storage', 'leveldb')) - } catch (err) { - if (isLockError(err)) { - throw new Error('源浏览器文件被占用:请完全退出对应的浏览器(含后台进程)后重试。') - } - throw new Error(`Local Storage 迁移失败: ${err instanceof Error ? err.message : String(err)}`) - } - - let cookies = false - let cookieNote: string | undefined - const keyInfo = readSourceKey(root) - const cookieDb = existsSync(join(sourceProfile, 'Network', 'Cookies')) - ? join(sourceProfile, 'Network', 'Cookies') - : existsSync(join(sourceProfile, 'Cookies')) - ? join(sourceProfile, 'Cookies') - : null - if (keyInfo.key && cookieDb) { - try { - mkdirSync(join(staging, 'Network'), { recursive: true }) - copyFile(cookieDb, join(staging, 'Network', 'Cookies')) - for (const suffix of ['-wal', '-shm']) { - if (existsSync(`${cookieDb}${suffix}`)) copyFile(`${cookieDb}${suffix}`, join(staging, 'Network', `Cookies${suffix}`)) - } - cookies = true - } catch (err) { - cookieNote = isLockError(err) - ? '源浏览器 Cookie 文件被占用:请完全退出该浏览器后重试。' - : err instanceof Error - ? err.message - : 'Cookie 迁移失败。' - } - } else { - cookieNote = keyInfo.note ?? '源浏览器没有可迁移的 Cookie 数据库。' - } - - return { cookies, localStorage, cookieNote } -} - -/** apply(LocalStorage):暂存 → 指定分区目录(目标应是未被 Chromium 打开过的目录) */ -function applyStagedLocalStorage(staging: string, targetPartitionDir: string): void { - copyLevelDbDir(join(staging, 'Local Storage', 'leveldb'), join(targetPartitionDir, 'Local Storage', 'leveldb')) -} - -type SourceCookieRead = - | { kind: 'ok'; cookies: BrowserCookieSeed[]; v20: number; undecryptable: number } - | { kind: 'unsupported'; note: string } - -/** - * 解密暂存的源 Cookies 数据库 → 明文 Cookie 种子列表(之后经会话 API 注入, - * 不碰分区文件,天然无锁)。返回 unsupported 表示当前环境无法解密。 - */ -async function readSourceCookies(root: string, staging: string): Promise { - if (process.platform !== 'win32') { - return { kind: 'unsupported', note: '当前平台的浏览器 Cookie 密钥绑定系统钥匙串,暂不支持迁移 Cookie(LocalStorage 登录态仍会导入)。' } - } - const keyInfo = readSourceKey(root) - 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),本次未导入 Cookie,可稍后重试。' } - } - - const db = new DatabaseSync(dbFile, { readOnly: true }) - try { - // expires_utc 是 1601 纪元的微秒 int64(超过 Number.MAX_SAFE_INTEGER, - // node:sqlite 裸读直接抛 ERR_OUT_OF_RANGE)——纪元换算放在 SQL 里做: - // int64 除法精确,结果秒值 ~4e8 在安全整数范围内 - const rows = db - .prepare( - 'SELECT host_key, name, value, encrypted_value, path, is_secure, is_httponly, ' + - 'CASE WHEN expires_utc > 0 THEN expires_utc / 1000000 - 11644473600 ELSE 0 END AS expires_sec, ' + - 'samesite FROM cookies' - ) - .all() - const cookies: BrowserCookieSeed[] = [] - let v20 = 0 - let undecryptable = 0 - for (const row of rows) { - try { - const r = row as { - host_key: string - name: string - value: string - /** node:sqlite 的 BLOB 是裸 Uint8Array(非 Buffer),用前必须转换 */ - encrypted_value: Uint8Array | null - path: string - is_secure: number - is_httponly: number - expires_sec: number - samesite: number | null - } - let value = r.value - if (!value && r.encrypted_value && r.encrypted_value.length > 3) { - const encrypted = Buffer.from(r.encrypted_value) - // v20 = 应用绑定加密(Chrome 127+ 逐步启用),第三方无法解密,单独计数便于如实提示 - if (encrypted.subarray(0, 3).toString('utf8') === 'v20') { - v20++ - continue - } - value = decryptChromeBlob(encrypted, key) - } - if (!value) { - undecryptable++ - continue - } - cookies.push({ - name: r.name, - value, - domain: r.host_key, - hostOnly: !r.host_key.startsWith('.'), - path: r.path || '/', - secure: r.is_secure === 1, - httpOnly: r.is_httponly === 1, - ...(r.expires_sec > 0 ? { expirationDate: r.expires_sec } : {}), - ...(mapSameSite(r.samesite) ? { sameSite: mapSameSite(r.samesite) } : {}) - }) - } catch { - undecryptable++ - } - } - return { kind: 'ok', cookies, v20, undecryptable } - } finally { - db.close() - } -} - -/** 导入互斥:同一时刻只允许一个导入在跑(分区轮换不是并发安全的) */ -let importInFlight = false - -/** 导入指定浏览器的 profile 配置到受管浏览器分区(无感热应用:无需重启应用,无搁置路径) */ -export async function importBrowserProfile(sourceId: string, profileDir: string): Promise { - if (importInFlight) throw new Error('已有导入正在进行,请稍候。') - importInFlight = true - try { - return await importBrowserProfileInner(sourceId, profileDir) - } finally { - importInFlight = false - } -} - -async function importBrowserProfileInner(sourceId: string, profileDir: string): Promise { - const browser = getBrowserController() - 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 + 源 Cookie 解密(源数据全部先抓到手,之后源浏览器锁不锁都无所谓) - const staging = join(partitionsRoot(), `.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 sourceRead: SourceCookieRead = collected.cookieNote - ? { kind: 'unsupported', note: collected.cookieNote } - : await readSourceCookies(root, staging) - - // 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, partitionDirOf(browser.partitionName())) - } catch (err) { - browser.restoreTabs(urls) - rmSync(staging, { recursive: true, force: true }) - 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 - if (sourceRead.cookies.length === 0 && sourceRead.v20 > 0) { - // 全量 v20:文件路径对 Chrome 127+ 已封死,明确引导走扩展备份通道 - cookieNote = `Chrome 已启用应用绑定加密(v20),${sourceRead.v20} 条 Cookie 在浏览器外无法解密;请用上方「Cookie 备份导入」补齐(配套扩展一键导出)。LocalStorage 已生效。` - } else { - const skipNotes: string[] = [] - if (sourceRead.v20 > 0) skipNotes.push(`${sourceRead.v20} 条为应用绑定加密(v20)无法迁移,可用「Cookie 备份导入」补齐`) - const failedCount = result.failed + sourceRead.undecryptable - if (failedCount > 0) skipNotes.push(`${failedCount} 条解密失败已跳过`) - if (skipNotes.length > 0) cookieNote = `源 Cookie ${injected} 条已注入,${skipNotes.join(',')}。` - } - } 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 }) - const info: BrowserImportInfo = { - sourceId, - browser: sourceName, - profile: profileName, - profileDir: dirName, - at: new Date().toISOString(), - 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: injected > 0, - localStorage: collected.localStorage, - ...(cookieNote ? { cookieNote } : {}), - message: `已从 ${sourceName}「${profileName}」导入:${summary}。${urls.length > 0 ? '原标签页已自动恢复。' : ''}${cookieNote ?? ''}` - } -} - -/** - * 导入配套扩展导出的 Cookie 备份文件(chrome.cookies API 明文通道——Chrome 127+ - * 应用绑定加密后浏览器外无法解密 Cookie,官方留给扩展的 API 仍返回明文)。 - * 纯会话 API 注入:无需重启、无需关闭标签,立即生效。 - */ -const MAX_BACKUP_BYTES = 32 * 1024 * 1024 -const MAX_BACKUP_COOKIES = 50_000 - -export async function importCookiesFromFile(filePath: string): Promise { - const browser = getBrowserController() - if (!/\.json$/i.test(filePath)) throw new Error('请选择扩展导出的 .json 备份文件。') - let raw: string - try { - if (statSync(filePath).size > MAX_BACKUP_BYTES) throw new Error('文件过大(超过 32MB),不是有效的备份文件。') - raw = readFileSync(filePath, 'utf8') - } catch (err) { - if (err instanceof Error && err.message.startsWith('文件过大')) throw err - throw new Error('读取备份文件失败,请重新选择。') - } - let parsed: { format?: unknown; version?: unknown; cookies?: unknown } - try { - parsed = JSON.parse(raw) as typeof parsed - } catch { - throw new Error('备份文件不是有效的 JSON,请用「ASO Cookie 导出助手」扩展重新导出。') - } - if (parsed.format !== 'aso-cookie-export' || !Array.isArray(parsed.cookies)) { - throw new Error('备份文件格式不识别,请用「ASO Cookie 导出助手」扩展导出。') - } - if (parsed.cookies.length === 0) throw new Error('备份文件里没有 Cookie。') - if (parsed.cookies.length > MAX_BACKUP_COOKIES) throw new Error('Cookie 数量异常(超过 5 万条),已拒绝导入。') - - const seeds: BrowserCookieSeed[] = [] - let invalid = 0 - for (const item of parsed.cookies) { - try { - const c = item as Record - if (typeof c.name !== 'string' || typeof c.value !== 'string' || typeof c.domain !== 'string' || !c.name || !c.domain) { - invalid++ - continue - } - seeds.push({ - name: c.name, - value: c.value, - domain: c.domain, - hostOnly: c.hostOnly === true, - path: typeof c.path === 'string' && c.path ? c.path : '/', - secure: c.secure === true, - httpOnly: c.httpOnly === true, - ...(typeof c.expirationDate === 'number' && c.expirationDate > 0 ? { expirationDate: c.expirationDate } : {}), - ...(c.sameSite === 'no_restriction' || c.sameSite === 'lax' || c.sameSite === 'strict' ? { sameSite: c.sameSite } : {}) - }) - } catch { - invalid++ - } - } - if (seeds.length === 0) throw new Error('备份文件里没有可用的 Cookie 条目。') - - const result = await browser.importCookies(seeds) - const previous = browser.importedInfo - browser.setImportedInfo({ - sourceId: 'file', - browser: 'Cookie 备份文件', - profile: '', - profileDir: '', - at: new Date().toISOString(), - cookies: result.imported > 0, - localStorage: previous?.localStorage ?? false, - ...(result.imported > 0 ? { cookieCount: result.imported } : {}), - note: result.failed > 0 || invalid > 0 ? `${result.failed + invalid} 条无效已跳过。` : undefined - }) - log.info('cookie backup imported', { injected: result.imported, failed: result.failed, invalid }) - return { - ok: result.imported > 0, - cookies: result.imported > 0, - localStorage: previous?.localStorage ?? false, - ...(result.failed > 0 || invalid > 0 ? { cookieNote: `${result.failed + invalid} 条无效已跳过。` } : {}), - message: - result.imported > 0 - ? `已从备份文件注入 Cookie ${result.imported} 条,立即生效。${result.failed + invalid > 0 ? `${result.failed + invalid} 条无效已跳过。` : ''}` - : '备份文件里的 Cookie 全部写入失败,请重试。' - } -} - -/** - * 启动早期调用(分区尚未被任何页面打开):清理上一代分区目录与残留暂存。 - * 失败只记日志(旧目录多留一启动无害,下次再试)。 - */ -export async function cleanupStaleBrowserPartitions(): Promise { - 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 }) -} diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 7ba8163..3f255c5 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -1,16 +1,9 @@ import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron' import { mkdirSync, readFileSync } from 'node:fs' -import { join } from 'node:path' import { userInfo } from 'node:os' import { AgentService } from './pi/agent-service' import { getPluginHost } from './plugins/plugin-host' import { getBrowserController } from './browser/browser-controller' -import { - cleanupStaleBrowserPartitions, - detectBrowserSources, - importBrowserProfile, - importCookiesFromFile -} from './browser/browser-profile' import { getLogger } from './logger' const log = getLogger('ipc') @@ -408,25 +401,6 @@ export function registerIpc(): AgentService { return browserPass(() => getBrowserController().applyLayout({ tabId, visible, revision, bounds: { x, y, width, height } })) }) - // ---- 本地浏览器配置导入 ---- - handle('browser:detectSources', () => browserPass(() => detectBrowserSources())) - handle('browser:importProfile', (_e, sourceId: unknown, profileDir: unknown) => { - if (typeof sourceId !== 'string' || typeof profileDir !== 'string') throw new Error('invalid import source') - return browserPass(() => importBrowserProfile(sourceId, profileDir)) - }) - - // Cookie 备份文件导入(Chrome 应用绑定加密下的扩展通道)+ 配套扩展目录 - handle('browser:importCookiesFile', (_e, path: unknown) => { - if (typeof path !== 'string' || !path) throw new Error('invalid cookie backup path') - return browserPass(() => importCookiesFromFile(path)) - }) - handle('browser:openExporterFolder', () => { - const folder = app.isPackaged - ? join(process.resourcesPath, 'cookie-exporter') - : join(app.getAppPath(), 'resources', 'cookie-exporter') - return shell.openPath(folder) - }) - // ---- 受管浏览器代理设置(persist:aso-browser 分区,保存即生效) ---- handle('browser:getProxy', () => getBrowserController().getProxySettings()) handle('browser:setProxy', (_e, input: unknown) => { @@ -440,8 +414,6 @@ export function registerIpc(): AgentService { // ---- 插件系统 ---- void app.whenReady().then(() => { - // 清理上一代受管分区与残留暂存(此刻无人持有句柄;导入已无搁置路径) - void cleanupStaleBrowserPartitions() // 插件启停/重载会重建 agent 运行时(extensionFactories 重跑) getPluginHost().setRuntimeRebuilder(() => service.rebuildRuntime()) }) diff --git a/apps/desktop/src/main/plugins/builtin/browser.ts b/apps/desktop/src/main/plugins/builtin/browser.ts index 311a01a..1f4c5b2 100644 --- a/apps/desktop/src/main/plugins/builtin/browser.ts +++ b/apps/desktop/src/main/plugins/builtin/browser.ts @@ -18,7 +18,7 @@ export const browserPlugin: BuiltinPlugin = { description: '受管浏览器', icon: 'Globe' }, - /** 宿主专属二级设置页(代理设置 + 登录态导入;见 SettingsModal 插件页特判) */ + /** 宿主专属二级设置页(代理设置;见 SettingsModal 插件页特判) */ settingsPage: true, main: (api) => { for (const tool of browserTools) { diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 177d271..79d9bbf 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -1,11 +1,9 @@ -import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron' +import { contextBridge, ipcRenderer, webFrame } from 'electron' import type { AgentUiEvent, AppSettings, AttachmentInfo, - BrowserImportResult, BrowserProxySettings, - BrowserSource, BrowserUiEvent, BrowserUiState, ChatResources, @@ -127,13 +125,6 @@ const api = { revision: number bounds: { x: number; y: number; width: number; height: number } }): Promise => ipcRenderer.invoke('browser:layout', layout), - browserDetectSources: (): Promise => ipcRenderer.invoke('browser:detectSources'), - browserImportProfile: (sourceId: string, profileDir: string): Promise => - ipcRenderer.invoke('browser:importProfile', sourceId, profileDir), - browserImportCookiesFile: (path: string): Promise => - ipcRenderer.invoke('browser:importCookiesFile', path), - browserOpenExporterFolder: (): Promise => ipcRenderer.invoke('browser:openExporterFolder'), - pathForFile: (file: File): string => webUtils.getPathForFile(file), browserGetProxy: (): Promise => ipcRenderer.invoke('browser:getProxy'), browserSetProxy: (input: BrowserProxySettings): Promise => ipcRenderer.invoke('browser:setProxy', input), diff --git a/apps/desktop/src/renderer/src/components/SettingsModal.tsx b/apps/desktop/src/renderer/src/components/SettingsModal.tsx index de46b6a..1be92c0 100644 --- a/apps/desktop/src/renderer/src/components/SettingsModal.tsx +++ b/apps/desktop/src/renderer/src/components/SettingsModal.tsx @@ -38,7 +38,6 @@ import { THEME_MODES, THEME_PRESETS, applyThemeColor } from '../lib/theme' import { usePluginList } from '../plugins/store' import { PluginIconValue } from '../plugins/plugin-icons' import { BrowserProxyForm } from './browser/BrowserProxyForm' -import { BrowserImportForm } from './browser/BrowserImportForm' type PluginSettingsTab = `plugin:${string}` type Tab = 'general' | 'model' | 'prompt' | 'plugins' | 'skills' | 'commands' | PluginSettingsTab @@ -1674,7 +1673,7 @@ export function SettingsModal({ {tab.startsWith('plugin:') && (() => { const pid = tab.slice(7) - // 内置浏览器:专属二级页(代理设置 + 登录态导入),不走向声明式设置表单 + // 内置浏览器:专属二级页(代理设置),不走向声明式设置表单 if (pid === 'browser') { return (
@@ -1684,12 +1683,6 @@ export function SettingsModal({
-
-
导入本机浏览器登录态
-
- -
-
) } diff --git a/apps/desktop/src/renderer/src/components/browser/BrowserImportForm.tsx b/apps/desktop/src/renderer/src/components/browser/BrowserImportForm.tsx deleted file mode 100644 index 89dc062..0000000 --- a/apps/desktop/src/renderer/src/components/browser/BrowserImportForm.tsx +++ /dev/null @@ -1,232 +0,0 @@ -/** - * 本机浏览器登录态导入表单(面板浮层与设置页二级页共用)。 - * 选择本机浏览器(Chrome/Edge/Brave/Chromium)的一个 Profile, - * 把各站点登录态(Cookie / Local Storage)迁移到受管浏览器分区。 - * 导入全程无感:无需重启应用;已导入的来源常驻展示,可一键「同步」复导。 - * 自含数据源:直接调用 window.api(browserDetectSources / browserImportProfile)。 - */ -import { useEffect, useState, type JSX } from 'react' -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(null) - const [sources, setSources] = useState(null) - const [selected, setSelected] = useState<{ sourceId: string; profileDir: string } | null>(null) - const [busy, setBusy] = useState(false) - const [result, setResult] = useState(null) - const [error, setError] = useState(null) - - useEffect(() => { - let alive = true - window.api - .browserGetState() - .then((s) => { - if (alive) setState(s) - }) - .catch(() => undefined) - const off = window.api.onBrowserEvent((event) => { - if (event.type === 'state' && alive) setState(event.state) - }) - return () => { - alive = false - off() - } - }, []) - - useEffect(() => { - let alive = true - window.api - .browserDetectSources() - .then((list) => { - if (!alive) return - setSources(list) - const first = list[0] - if (first?.profiles[0]) setSelected({ sourceId: first.id, profileDir: first.profiles[0].dir }) - }) - .catch((err) => { - if (alive) setError(err instanceof Error ? err.message : String(err)) - }) - return () => { - alive = false - } - }, []) - - const runImport = (target: { sourceId: string; profileDir: string } | null): void => { - if (!target || busy) return - setBusy(true) - setError(null) - setResult(null) - window.api - .browserImportProfile(target.sourceId, target.profileDir) - .then(setResult) - .catch((err) => setError(err instanceof Error ? err.message : String(err))) - .finally(() => setBusy(false)) - } - - const runBackupImport = (file: File | null): void => { - if (!file || busy) return - setBusy(true) - setError(null) - setResult(null) - try { - const path = window.api.pathForFile(file) - window.api - .browserImportCookiesFile(path) - .then(setResult) - .catch((err) => setError(err instanceof Error ? err.message : String(err))) - .finally(() => setBusy(false)) - } catch (err) { - setBusy(false) - setError(err instanceof Error ? err.message : String(err)) - } - } - - const imported = state?.imported ?? null - - return ( -
-

- 迁移 Cookie / Local Storage 登录态,导入后立即生效。导入前请 - 完全退出源浏览器(含后台进程)。 -

- - {/* Chrome 新版应用绑定加密:文件导入拿不到 Cookie,走配套扩展的官方明文通道 */} -
-
- Cookie 备份导入 -
- - -
-
-
- Chrome 新版已加密 Cookie(文件导入拿不到)。用配套扩展导出备份:点「扩展目录」,在 Chrome 的 - chrome://extensions 加载该目录后一键导出,再在这里选择备份文件,立即生效。 -
-
- - {imported && ( -
-
- 已导入 - - {imported.browser} - {imported.profile && · {imported.profile}} - {imported.profileDir && {imported.profileDir}} - - {imported.sourceId && imported.profileDir && ( - - )} -
-
- {formatAt(imported.at)} - {imported.cookieCount ? ` · Cookie ${imported.cookieCount} 条` : imported.cookies ? ' · 含 Cookie' : ''} - {imported.localStorage ? ' · LocalStorage' : ''} -
- {imported.note &&
{imported.note}
} -
- )} - - {sources === null && !error &&

正在检测本机浏览器…

} - {sources?.length === 0 && ( -

未检测到可导入的本机浏览器(支持 Chrome / Edge / Brave / Chromium)

- )} - -
- {sources?.map((source) => ( -
-
- - {source.name} - ({source.profiles.length} 个配置) -
-
- {source.profiles.map((profile) => { - const active = selected?.sourceId === source.id && selected.profileDir === profile.dir - return ( - - ) - })} -
-
- ))} -
- - {error && ( -
{error}
- )} - {result && ( -
- {result.message} -
- )} - -
- -
-
- ) -} diff --git a/apps/desktop/src/renderer/src/components/browser/BrowserPanel.tsx b/apps/desktop/src/renderer/src/components/browser/BrowserPanel.tsx index d18dddd..d00151b 100644 --- a/apps/desktop/src/renderer/src/components/browser/BrowserPanel.tsx +++ b/apps/desktop/src/renderer/src/components/browser/BrowserPanel.tsx @@ -4,7 +4,6 @@ import { LuArrowRight, LuChevronDown, LuCircleAlert, - LuDownload, LuGlobe, LuNetwork, LuPlus, @@ -13,14 +12,13 @@ import { } from 'react-icons/lu' import type { BrowserUiState } from '@shared/types' import { BrowserSlot } from './BrowserSlot' -import { BrowserImportForm } from './BrowserImportForm' import { BrowserProxyForm } from './BrowserProxyForm' /** - * 内置受管浏览器的「chrome」部分(页签栏/地址栏/工具条/导入浮层)。 + * 内置受管浏览器的「chrome」部分(页签栏/地址栏/工具条/代理浮层)。 * 作为侧边面板的一个标签内容渲染;页面本体是主进程的原生 WebContentsView * (盖在 DOM 之上),BrowserSlot 量出页面区域矩形后由主进程对齐原生视图。 - * 任何全屏浮层(设置/搜索/导入)出现时必须卸载 Slot 隐藏原生视图。 + * 任何全屏浮层(设置/搜索/代理)出现时必须卸载 Slot 隐藏原生视图。 */ interface BrowserPanelProps { @@ -43,7 +41,6 @@ interface BrowserPanelProps { export function BrowserPanel(props: BrowserPanelProps): JSX.Element { const { state, suspended } = props const [address, setAddress] = useState('') - const [importOpen, setImportOpen] = useState(false) const [proxyOpen, setProxyOpen] = useState(false) const [tabListOpen, setTabListOpen] = useState(false) const [tabMenu, setTabMenu] = useState<{ x: number; y: number; tabId: string } | null>(null) @@ -82,7 +79,7 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element { const riskBanner = state && !state.riskAcknowledged // 下拉/右键菜单会伸到页面区域上方,原生视图必须暂时让位(原生层级永远在 DOM 之上) - const overlayOpen = importOpen || proxyOpen || tabListOpen || tabMenu !== null + const overlayOpen = proxyOpen || tabListOpen || tabMenu !== null // 需要暂时隐藏原生视图的原因(宿主全屏浮层 + 本面板菜单) const hideCause = suspended || overlayOpen @@ -356,14 +353,6 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element { className="min-w-0 flex-1 bg-transparent text-[12px] text-ink-2 outline-none placeholder:text-ink-3" /> - - -
- -
- - ) -} - function ProxyOverlay({ onClose }: { onClose: () => void }): JSX.Element { return (
diff --git a/apps/desktop/src/renderer/src/hooks/useBrowser.ts b/apps/desktop/src/renderer/src/hooks/useBrowser.ts index 63f7612..c764c80 100644 --- a/apps/desktop/src/renderer/src/hooks/useBrowser.ts +++ b/apps/desktop/src/renderer/src/hooks/useBrowser.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from 'react' -import type { BrowserImportResult, BrowserSource, BrowserUiState } from '@shared/types' +import type { BrowserUiState } from '@shared/types' /** * 内置受管浏览器面板状态与操作。 @@ -16,8 +16,6 @@ export function useBrowser(): { closeTab: (tabId: string) => void closeBrowser: () => void acknowledgeRisk: () => void - detectSources: () => Promise - importProfile: (sourceId: string, profileDir: string) => Promise } { const [state, setState] = useState(null) @@ -50,8 +48,6 @@ export function useBrowser(): { selectTab: wrap((tabId: string) => window.api.browserSelectTab(tabId)), closeTab: wrap((tabId: string) => window.api.browserCloseTab(tabId)), closeBrowser: wrap(() => window.api.browserClose()), - acknowledgeRisk: wrap(() => window.api.browserAcknowledgeRisk()), - detectSources: () => window.api.browserDetectSources(), - importProfile: (sourceId: string, profileDir: string) => window.api.browserImportProfile(sourceId, profileDir) + acknowledgeRisk: wrap(() => window.api.browserAcknowledgeRisk()) } } diff --git a/apps/desktop/src/renderer/src/lib/api.ts b/apps/desktop/src/renderer/src/lib/api.ts index ebbaca0..52867c5 100644 --- a/apps/desktop/src/renderer/src/lib/api.ts +++ b/apps/desktop/src/renderer/src/lib/api.ts @@ -2,9 +2,7 @@ import type { AgentUiEvent, AppSettings, AttachmentInfo, - BrowserImportResult, BrowserProxySettings, - BrowserSource, BrowserUiEvent, BrowserUiState, ChatResources, @@ -37,8 +35,6 @@ export type { SessionMeta, SessionState, SessionStatsInfo, - BrowserImportResult, - BrowserSource, BrowserUiEvent, BrowserUiState } @@ -114,11 +110,6 @@ declare global { revision: number bounds: { x: number; y: number; width: number; height: number } }) => Promise - browserDetectSources: () => Promise - browserImportProfile: (sourceId: string, profileDir: string) => Promise - browserImportCookiesFile: (path: string) => Promise - browserOpenExporterFolder: () => Promise - pathForFile: (file: File) => string browserGetProxy: () => Promise browserSetProxy: (input: BrowserProxySettings) => Promise onBrowserEvent: (callback: (event: BrowserUiEvent) => void) => () => void diff --git a/apps/desktop/src/shared/types.ts b/apps/desktop/src/shared/types.ts index 44969af..c6305c4 100644 --- a/apps/desktop/src/shared/types.ts +++ b/apps/desktop/src/shared/types.ts @@ -271,19 +271,6 @@ 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 -} - /** 浏览器面板完整状态(事件与查询共用同一结构) */ export interface BrowserUiState { /** 是否存在受管浏览器会话(有标签页即视为打开) */ @@ -296,7 +283,6 @@ export interface BrowserUiState { recentClosed: BrowserClosedTab[] /** 最近一次 Agent 浏览器操作描述(面板底部轨迹) */ trace: string | null - imported?: BrowserImportInfo | null /** 当前代理设置(面板展示与编辑;变更即广播) */ proxy: BrowserProxySettings } @@ -313,31 +299,6 @@ export interface BrowserProxySettings { export type BrowserUiEvent = { type: 'state'; state: BrowserUiState } -/** 本地浏览器(Chromium 系)下的一个 profile */ -export interface BrowserProfileMeta { - /** profile 目录名(Default / Profile 1 …) */ - dir: string - /** 展示名(Preferences 里的名称或目录名) */ - name: string -} - -/** 检测到的可导入浏览器来源 */ -export interface BrowserSource { - id: string - name: string - userDataDir: string - profiles: BrowserProfileMeta[] -} - -/** 导入结果(主进程返回;全程无感,无需重启应用;cookieNote 说明跳过原因) */ -export interface BrowserImportResult { - ok: boolean - cookies: boolean - localStorage: boolean - cookieNote?: string - message: string -} - export type AgentUiEvent = | { type: 'session_ready'; state: SessionState } | { type: 'session_error'; message: string }