From 573ac5bcdecefe3518202b67483dcc474bd46ce1 Mon Sep 17 00:00:00 2001 From: Rick Ye Date: Sun, 13 Sep 2026 01:22:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(browser):=20Cookie=20=E5=A4=87=E4=BB=BD?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E2=80=94=E2=80=94=E9=85=8D=E5=A5=97=E6=89=A9?= =?UTF-8?q?=E5=B1=95=E8=B5=B0=20chrome.cookies=20=E5=AE=98=E6=96=B9?= =?UTF-8?q?=E6=98=8E=E6=96=87=E9=80=9A=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/cookie-exporter/README.md | 15 +++ .../resources/cookie-exporter/manifest.json | 11 +++ .../resources/cookie-exporter/popup.html | 58 +++++++++++ .../resources/cookie-exporter/popup.js | 47 +++++++++ .../src/main/browser/browser-profile.ts | 98 ++++++++++++++++++- apps/desktop/src/main/ipc.ts | 20 +++- apps/desktop/src/preload/index.ts | 6 +- .../components/browser/BrowserImportForm.tsx | 59 ++++++++++- apps/desktop/src/renderer/src/lib/api.ts | 3 + 9 files changed, 309 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/resources/cookie-exporter/README.md create mode 100644 apps/desktop/resources/cookie-exporter/manifest.json create mode 100644 apps/desktop/resources/cookie-exporter/popup.html create mode 100644 apps/desktop/resources/cookie-exporter/popup.js diff --git a/apps/desktop/resources/cookie-exporter/README.md b/apps/desktop/resources/cookie-exporter/README.md new file mode 100644 index 0000000..d31e068 --- /dev/null +++ b/apps/desktop/resources/cookie-exporter/README.md @@ -0,0 +1,15 @@ +# 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 new file mode 100644 index 0000000..6ed129b --- /dev/null +++ b/apps/desktop/resources/cookie-exporter/manifest.json @@ -0,0 +1,11 @@ +{ + "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 new file mode 100644 index 0000000..4bd9130 --- /dev/null +++ b/apps/desktop/resources/cookie-exporter/popup.html @@ -0,0 +1,58 @@ + + + + + + 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 new file mode 100644 index 0000000..6222f33 --- /dev/null +++ b/apps/desktop/resources/cookie-exporter/popup.js @@ -0,0 +1,47 @@ +// 导出全部 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-profile.ts b/apps/desktop/src/main/browser/browser-profile.ts index 2798ac2..1b8375a 100644 --- a/apps/desktop/src/main/browser/browser-profile.ts +++ b/apps/desktop/src/main/browser/browser-profile.ts @@ -438,11 +438,16 @@ async function importBrowserProfileInner(sourceId: string, profileDir: string): if (sourceRead.kind === 'ok') { const result = await browser.importCookies(sourceRead.cookies) injected = result.imported - const skipNotes: string[] = [] - if (sourceRead.v20 > 0) skipNotes.push(`${sourceRead.v20} 条为应用绑定加密(v20)无法迁移`) - const failedCount = result.failed + sourceRead.undecryptable - if (failedCount > 0) skipNotes.push(`${failedCount} 条解密失败已跳过`) - if (skipNotes.length > 0) cookieNote = `源 Cookie ${injected} 条已注入,${skipNotes.join(',')}。` + 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 } @@ -480,6 +485,89 @@ async function importBrowserProfileInner(sourceId: string, profileDir: string): } } +/** + * 导入配套扩展导出的 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 全部写入失败,请重试。' + } +} + /** * 启动早期调用(分区尚未被任何页面打开):清理上一代分区目录与残留暂存。 * 失败只记日志(旧目录多留一启动无害,下次再试)。 diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index d69a6a9..7ba8163 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -1,10 +1,16 @@ 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 } from './browser/browser-profile' +import { + cleanupStaleBrowserPartitions, + detectBrowserSources, + importBrowserProfile, + importCookiesFromFile +} from './browser/browser-profile' import { getLogger } from './logger' const log = getLogger('ipc') @@ -409,6 +415,18 @@ export function registerIpc(): AgentService { 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) => { diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index f9cc1d1..177d271 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -1,4 +1,4 @@ -import { contextBridge, ipcRenderer, webFrame } from 'electron' +import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron' import type { AgentUiEvent, AppSettings, @@ -130,6 +130,10 @@ const api = { 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/browser/BrowserImportForm.tsx b/apps/desktop/src/renderer/src/components/browser/BrowserImportForm.tsx index 94c6737..89dc062 100644 --- a/apps/desktop/src/renderer/src/components/browser/BrowserImportForm.tsx +++ b/apps/desktop/src/renderer/src/components/browser/BrowserImportForm.tsx @@ -71,6 +71,24 @@ export function BrowserImportForm(): JSX.Element { .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 ( @@ -80,12 +98,51 @@ export function BrowserImportForm(): JSX.Element { 完全退出源浏览器(含后台进程)。

+ {/* Chrome 新版应用绑定加密:文件导入拿不到 Cookie,走配套扩展的官方明文通道 */} +
+
+ Cookie 备份导入 +
+ + +
+
+
+ Chrome 新版已加密 Cookie(文件导入拿不到)。用配套扩展导出备份:点「扩展目录」,在 Chrome 的 + chrome://extensions 加载该目录后一键导出,再在这里选择备份文件,立即生效。 +
+
+ {imported && (
已导入 - {imported.browser} · {imported.profile} + {imported.browser} + {imported.profile && · {imported.profile}} {imported.profileDir && {imported.profileDir}} {imported.sourceId && imported.profileDir && ( diff --git a/apps/desktop/src/renderer/src/lib/api.ts b/apps/desktop/src/renderer/src/lib/api.ts index 516ebc6..ebbaca0 100644 --- a/apps/desktop/src/renderer/src/lib/api.ts +++ b/apps/desktop/src/renderer/src/lib/api.ts @@ -116,6 +116,9 @@ declare global { }) => 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