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
@@ -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<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 { 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) => {
+5 -1
View File
@@ -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<BrowserSource[]> => ipcRenderer.invoke('browser:detectSources'),
browserImportProfile: (sourceId: string, profileDir: string): Promise<BrowserImportResult> =>
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'),
browserSetProxy: (input: BrowserProxySettings): Promise<BrowserProxySettings> =>
ipcRenderer.invoke('browser:setProxy', input),
@@ -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 {
<span className="font-medium text-ink-2">完全退出</span>源浏览器(含后台进程)。
</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 && (
<div className="mb-3 rounded border border-line bg-inset px-2.5 py-2">
<div className="flex items-center gap-2">
<span className="shrink-0 text-[11px] text-ink-3">已导入</span>
<span className="min-w-0 flex-1 truncate text-[12px] text-ink">
{imported.browser} · {imported.profile}
{imported.browser}
{imported.profile && <span> · {imported.profile}</span>}
{imported.profileDir && <span className="ml-1.5 text-[10.5px] text-ink-3">{imported.profileDir}</span>}
</span>
{imported.sourceId && imported.profileDir && (
+3
View File
@@ -116,6 +116,9 @@ declare global {
}) => Promise<void>
browserDetectSources: () => Promise<BrowserSource[]>
browserImportProfile: (sourceId: string, profileDir: string) => Promise<BrowserImportResult>
browserImportCookiesFile: (path: string) => Promise<BrowserImportResult>
browserOpenExporterFolder: () => Promise<string>
pathForFile: (file: File) => string
browserGetProxy: () => Promise<BrowserProxySettings>
browserSetProxy: (input: BrowserProxySettings) => Promise<BrowserProxySettings>
onBrowserEvent: (callback: (event: BrowserUiEvent) => void) => () => void