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