feat(browser): Cookie 备份导入——配套扩展走 chrome.cookies 官方明文通道

This commit is contained in:
Rick Ye
2026-09-13 01:22:21 +08:00
parent 2efcd8e346
commit 573ac5bcde
9 changed files with 309 additions and 8 deletions
@@ -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 系浏览器同样适用(各自扩展页加载本目录即可)。
> 导出的备份文件包含明文登录凭据,用完建议删除,不要发给别人。
@@ -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": ["<all_urls>"],
"action": {
"default_popup": "popup.html"
}
}
@@ -0,0 +1,58 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ASO Cookie 导出助手</title>
<style>
body {
width: 280px;
margin: 0;
padding: 14px;
font: 13px/1.6 system-ui, 'Segoe UI', 'Microsoft YaHei', sans-serif;
color: #1f2328;
background: #fff;
}
h1 {
margin: 0 0 4px;
font-size: 14px;
}
p {
margin: 0 0 12px;
color: #6b7280;
font-size: 12px;
}
button {
width: 100%;
padding: 8px 0;
font-size: 13px;
color: #fff;
background: #2563eb;
border: 0;
border-radius: 8px;
cursor: pointer;
}
button:hover {
background: #1d4ed8;
}
button:disabled {
opacity: 0.5;
cursor: default;
}
#status {
margin-top: 10px;
min-height: 18px;
font-size: 12px;
color: #374151;
word-break: break-all;
}
</style>
</head>
<body>
<h1>Cookie 导出助手</h1>
<p>导出本浏览器全部 Cookie(明文),到 Inkreach Agent 的 设置 → 浏览器 → Cookie 备份导入 里使用。</p>
<button id="export">导出全部 Cookie</button>
<div id="status"></div>
<script src="popup.js"></script>
</body>
</html>
@@ -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
}
})
@@ -438,11 +438,16 @@ async function importBrowserProfileInner(sourceId: string, profileDir: string):
if (sourceRead.kind === 'ok') { if (sourceRead.kind === 'ok') {
const result = await browser.importCookies(sourceRead.cookies) const result = await browser.importCookies(sourceRead.cookies)
injected = result.imported injected = result.imported
const skipNotes: string[] = [] if (sourceRead.cookies.length === 0 && sourceRead.v20 > 0) {
if (sourceRead.v20 > 0) skipNotes.push(`${sourceRead.v20} 条为应用绑定加密(v20)无法迁移`) // 全量 v20:文件路径对 Chrome 127+ 已封死,明确引导走扩展备份通道
const failedCount = result.failed + sourceRead.undecryptable cookieNote = `Chrome 已启用应用绑定加密(v20),${sourceRead.v20} 条 Cookie 在浏览器外无法解密;请用上方「Cookie 备份导入」补齐(配套扩展一键导出)。LocalStorage 已生效。`
if (failedCount > 0) skipNotes.push(`${failedCount} 条解密失败已跳过`) } else {
if (skipNotes.length > 0) cookieNote = `源 Cookie ${injected} 条已注入,${skipNotes.join(',')}。` 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 { } else {
cookieNote = sourceRead.note 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<BrowserImportResult> {
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<string, unknown>
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 全部写入失败,请重试。'
}
}
/** /**
* 启动早期调用(分区尚未被任何页面打开):清理上一代分区目录与残留暂存。 * 启动早期调用(分区尚未被任何页面打开):清理上一代分区目录与残留暂存。
* 失败只记日志(旧目录多留一启动无害,下次再试)。 * 失败只记日志(旧目录多留一启动无害,下次再试)。
+19 -1
View File
@@ -1,10 +1,16 @@
import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron' import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron'
import { mkdirSync, readFileSync } from 'node:fs' import { mkdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { userInfo } from 'node:os' import { userInfo } from 'node:os'
import { AgentService } from './pi/agent-service' import { AgentService } from './pi/agent-service'
import { getPluginHost } from './plugins/plugin-host' import { getPluginHost } from './plugins/plugin-host'
import { getBrowserController } from './browser/browser-controller' import { getBrowserController } from './browser/browser-controller'
import { cleanupStaleBrowserPartitions, detectBrowserSources, importBrowserProfile } from './browser/browser-profile' import {
cleanupStaleBrowserPartitions,
detectBrowserSources,
importBrowserProfile,
importCookiesFromFile
} from './browser/browser-profile'
import { getLogger } from './logger' import { getLogger } from './logger'
const log = getLogger('ipc') const log = getLogger('ipc')
@@ -409,6 +415,18 @@ export function registerIpc(): AgentService {
return browserPass(() => importBrowserProfile(sourceId, profileDir)) 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 分区,保存即生效) ---- // ---- 受管浏览器代理设置(persist:aso-browser 分区,保存即生效) ----
handle('browser:getProxy', () => getBrowserController().getProxySettings()) handle('browser:getProxy', () => getBrowserController().getProxySettings())
handle('browser:setProxy', (_e, input: unknown) => { handle('browser:setProxy', (_e, input: unknown) => {
+5 -1
View File
@@ -1,4 +1,4 @@
import { contextBridge, ipcRenderer, webFrame } from 'electron' import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron'
import type { import type {
AgentUiEvent, AgentUiEvent,
AppSettings, AppSettings,
@@ -130,6 +130,10 @@ const api = {
browserDetectSources: (): Promise<BrowserSource[]> => ipcRenderer.invoke('browser:detectSources'), browserDetectSources: (): Promise<BrowserSource[]> => ipcRenderer.invoke('browser:detectSources'),
browserImportProfile: (sourceId: string, profileDir: string): Promise<BrowserImportResult> => browserImportProfile: (sourceId: string, profileDir: string): Promise<BrowserImportResult> =>
ipcRenderer.invoke('browser:importProfile', sourceId, profileDir), ipcRenderer.invoke('browser:importProfile', sourceId, profileDir),
browserImportCookiesFile: (path: string): Promise<BrowserImportResult> =>
ipcRenderer.invoke('browser:importCookiesFile', path),
browserOpenExporterFolder: (): Promise<string> => ipcRenderer.invoke('browser:openExporterFolder'),
pathForFile: (file: File): string => webUtils.getPathForFile(file),
browserGetProxy: (): Promise<BrowserProxySettings> => ipcRenderer.invoke('browser:getProxy'), browserGetProxy: (): Promise<BrowserProxySettings> => ipcRenderer.invoke('browser:getProxy'),
browserSetProxy: (input: BrowserProxySettings): Promise<BrowserProxySettings> => browserSetProxy: (input: BrowserProxySettings): Promise<BrowserProxySettings> =>
ipcRenderer.invoke('browser:setProxy', input), ipcRenderer.invoke('browser:setProxy', input),
@@ -71,6 +71,24 @@ export function BrowserImportForm(): JSX.Element {
.finally(() => setBusy(false)) .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 const imported = state?.imported ?? null
return ( return (
@@ -80,12 +98,51 @@ export function BrowserImportForm(): JSX.Element {
<span className="font-medium text-ink-2">完全退出</span>源浏览器(含后台进程)。 <span className="font-medium text-ink-2">完全退出</span>源浏览器(含后台进程)。
</p> </p>
{/* Chrome 新版应用绑定加密:文件导入拿不到 Cookie,走配套扩展的官方明文通道 */}
<div className="mb-3 rounded border border-line px-2.5 py-2">
<div className="flex items-center justify-between gap-2">
<span className="text-[12px] font-medium text-ink-2">Cookie 备份导入</span>
<div className="flex shrink-0 items-center gap-1.5">
<button
type="button"
disabled={busy}
onClick={() => void window.api.browserOpenExporterFolder()}
className="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"
>
扩展目录
</button>
<label
className={`cursor-pointer rounded border border-line-strong px-2 py-0.5 text-[11px] text-ink-2 transition-colors hover:bg-hover hover:text-ink ${
busy ? 'pointer-events-none opacity-40' : ''
}`}
>
选择备份文件
<input
type="file"
accept=".json,application/json"
className="hidden"
disabled={busy}
onChange={(e) => {
runBackupImport(e.target.files?.[0] ?? null)
e.currentTarget.value = ''
}}
/>
</label>
</div>
</div>
<div className="mt-1 text-[10.5px] leading-relaxed text-ink-3">
Chrome 新版已加密 Cookie(文件导入拿不到)。用配套扩展导出备份:点「扩展目录」,在 Chrome 的
chrome://extensions 加载该目录后一键导出,再在这里选择备份文件,立即生效。
</div>
</div>
{imported && ( {imported && (
<div className="mb-3 rounded border border-line bg-inset px-2.5 py-2"> <div className="mb-3 rounded border border-line bg-inset px-2.5 py-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="shrink-0 text-[11px] text-ink-3">已导入</span> <span className="shrink-0 text-[11px] text-ink-3">已导入</span>
<span className="min-w-0 flex-1 truncate text-[12px] text-ink"> <span className="min-w-0 flex-1 truncate text-[12px] text-ink">
{imported.browser} · {imported.profile} {imported.browser}
{imported.profile && <span> · {imported.profile}</span>}
{imported.profileDir && <span className="ml-1.5 text-[10.5px] text-ink-3">{imported.profileDir}</span>} {imported.profileDir && <span className="ml-1.5 text-[10.5px] text-ink-3">{imported.profileDir}</span>}
</span> </span>
{imported.sourceId && imported.profileDir && ( {imported.sourceId && imported.profileDir && (
+3
View File
@@ -116,6 +116,9 @@ declare global {
}) => Promise<void> }) => Promise<void>
browserDetectSources: () => Promise<BrowserSource[]> browserDetectSources: () => Promise<BrowserSource[]>
browserImportProfile: (sourceId: string, profileDir: string) => Promise<BrowserImportResult> browserImportProfile: (sourceId: string, profileDir: string) => Promise<BrowserImportResult>
browserImportCookiesFile: (path: string) => Promise<BrowserImportResult>
browserOpenExporterFolder: () => Promise<string>
pathForFile: (file: File) => string
browserGetProxy: () => Promise<BrowserProxySettings> browserGetProxy: () => Promise<BrowserProxySettings>
browserSetProxy: (input: BrowserProxySettings) => Promise<BrowserProxySettings> browserSetProxy: (input: BrowserProxySettings) => Promise<BrowserProxySettings>
onBrowserEvent: (callback: (event: BrowserUiEvent) => void) => () => void onBrowserEvent: (callback: (event: BrowserUiEvent) => void) => () => void