Files
qiming/qimingclaw/crates/agent-electron-client/docs/architecture/embedded-webview-cookie-sync.md

341 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
version: 1.3
last-updated: 2026-04-09
status: stable
---
# 内嵌 Webview 会话浏览器 + Cookie 登录态同步
> 本文档描述将会话页面从系统浏览器迁移至客户端内嵌 webview 的方案,以及通过 reg 接口返回的 `token` 自动同步 httpOnly cookie 实现免登录打通的设计。
---
## 背景与动机
| 改动前 | 改动后 |
|--------|--------|
| 点击「开始会话」调用 `shell.openExternal` 在系统浏览器打开 | 在 ClientPage 内嵌 `<webview>` 渲染会话页面 |
| 用户需要在外部浏览器重新登录 | 通过 cookie 同步自动携带登录态,免二次登录 |
| 体验割裂,无法与客户端 UI 联动 | 统一在客户端窗口内操作,支持返回/刷新工具栏 |
---
## 架构总览
```
┌──────────────────────────────────────────────────────────────┐
│ Electron Main Process │
│ │
│ appHandlers.ts │
│ ┌──────────────────────────────────────┐ │
│ │ session:setCookie IPC handler │ │
│ │ → electronSession.defaultSession │ │
│ │ .cookies.set({ httpOnly, secure }) │ │
│ └──────────────────────────────────────┘ │
│ │
│ main.ts │
│ ┌──────────────────────────────────────┐ │
│ │ webPreferences: { webviewTag: true } │ │
│ └──────────────────────────────────────┘ │
└──────────────────────────────┬───────────────────────────────┘
│ IPC
┌──────────────────────────────────────────────────────────────┐
│ Electron Renderer Process │
│ │
│ ClientPage.tsx │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ handleStartSession() │ │
│ │ 1. 读取 auth.token (settings) │ │
│ │ 2. 调用 session.setCookie → main 进程设置 cookie │ │
│ │ 3. setWebviewVisible(true) │ │
│ │ │ │
│ │ <webview src={redirectUrl}> │ │
│ │ └── 使用 defaultSession自动携带 ticket cookie │ │
│ └──────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
```
---
## 数据流
### Token 获取与持久化
```
服务端 /api/sandbox/config/reg
└── 返回 { configKey, serverHost, serverPort, token, ... }
├── loginAndRegister() ── if (response.token) → 写入 AUTH_TOKEN + domain_token + 立即同步 cookie
├── reRegisterClient() ── if (response.token) → 同上(自动重注册场景)
└── syncConfigToServer() ── if (response.token) → 同上(手动配置同步场景)
```
三个入口的 token 处理逻辑一致:
```
reg 返回 token
① AUTH_TOKEN = token ← one-shot给后续 webview 打开时用
② domain_token:{domain} = token ← 域名级缓存,兜底
③ syncSessionCookie(domain, token) ← 立即写 webview cookie
├─ 成功 → AUTH_TOKEN = null ← 已消费,清空
└─ 失败 → 保留 AUTH_TOKEN ← 等下次 webview 打开再同步
```
token 存入 SQLite通过 `settingsSet`与其他认证字段username、configKey同级管理。密码不持久化。
### Cookie 同步时序
```
syncCookieAndBuildUrl() — 打开 webview 前调用
├── 1. getCurrentAuth() → 获取 domain、configId
├── 2. settingsGet('auth.token') → 读取 one-shot token
│ ├── 有值 → tokenSource = "one_shot"
│ └── 无值 → settingsGet('domain_token:{domain}') → 读取域名缓存 token
│ └── 有值 → tokenSource = "domain_cache"
├── 3. token 不存在?
│ └── 是 → 跳过同步(不清空现有 cookie直接返回 buildUrl()
├── 4. parseJwtExpDate(token) → 检查 JWT exp 是否过期30s 宽限容忍时钟偏移)
│ └── 已过期exp + 30s ≤ now
│ └── 是 → 只清除对应来源缓存one-shot 清 AUTH_TOKENdomain_cache 清 domain key
│ 跳过 cookie 覆写,直接返回 buildUrl()
├── 5. token 有效 → syncSessionCookie(domain, token)
│ ├── electronAPI.session.setCookie({
│ │ url: domain, // e.g. "https://agent.qiming.com"
│ │ name: 'ticket',
│ │ value: token,
│ │ // 不设 domain → host-only cookie
│ │ // 不设 secure → 主进程根据 URL scheme 自动判断
│ │ httpOnly: true,
│ │ })
│ │ │
│ │ ├── [Main Process] removeSameNameCookies() ← 清除所有旧 ticket cookie
│ │ └── [Main Process] electronSession.defaultSession.cookies.set(...)
│ │ ├── secure: URL 以 https:// 开头 → true否则 false
│ │ ├── secure=true 时设 sameSite: 'no_restriction'
│ │ └── expirationDate: 从 JWT exp 解析,兜底 7 天
│ │
│ ├── 成功 → settingsSet('auth.token', null) ← 消费 one-shot token
│ └── 失败 → 保留 token抛出异常下次重试
└── 6. return buildUrl(domain, configId)
└── <webview src={redirectUrl}> 发起请求
├── 请求自动携带 ticket cookie (host-only, httpOnly)
└── 服务端校验通过 → 免登录加载页面
```
### 清除时机
| 场景 | 动作 |
|------|------|
| 退出登录 (`clearAuthInfo`) | `settingsSet('auth.token', null)` |
| 退出登录 (`handleLogout`) | `setWebviewVisible(false)` — 关闭 webview |
| reg 返回新 token | 覆盖旧值login 和 sync 两个入口均会更新) |
| cookie 同步成功 | `settingsSet('auth.token', null)` — 消费 one-shot token |
| 缓存 token 已过期 | 只清除对应来源one-shot → `AUTH_TOKEN`domain cache → domain key |
| webview 内重新登录 (`persistTicketCookie`) | 清除 `AUTH_TOKEN` + domain key刷盘 cookie |
### Webview 重新登录处理
```
webview 内 token 过期 → 服务端跳转 /login
用户在 webview 内重新登录 → 服务端 Set-Cookie: ticket=新token
EmbeddedWebview 检测到从 /login 跳转到非 login 页面
调用 persistTicketCookie(domain)
├── flushStore() — 确保新 cookie 写入磁盘
└── 清除 AUTH_TOKEN + domain_token:{domain} — 防止旧缓存覆盖新 ticket
```
**防御性兜底**:即使 `persistTicketCookie` 未被调用(如 race condition`syncCookieAndBuildUrl` 在下次打开 webview 时仍会检查 JWT exp过期的缓存 token 不会覆盖现有 cookie。
---
## 修改文件清单
| 文件 | 层 | 修改内容 |
|------|----|---------|
| `src/renderer/services/core/api.ts` | 类型 | `ClientRegisterResponse` 增加 `token?: string` |
| `src/shared/constants.ts` | 常量 | `AUTH_KEYS` 增加 `AUTH_TOKEN: 'auth.token'` |
| `src/renderer/services/core/auth.ts` | 服务 | `loginAndRegister` / `syncConfigToServer` 持久化 token`clearAuthInfo` 清除 token |
| `src/main/main.ts` | 主进程 | `webPreferences` 增加 `webviewTag: true` |
| `src/main/ipc/appHandlers.ts` | IPC | 新增 `session:setCookie` handler |
| `src/preload/index.ts` | Preload | 暴露 `session.setCookie` |
| `src/shared/types/electron.d.ts` | 类型 | `ElectronAPI.session` 替换为 `setCookie`;移除废弃的 `Session`/`Message` 接口和 `message` 块 |
| `src/shared/types/webview.d.ts` | 类型 | **新建**`<webview>` JSX IntrinsicElements 声明 |
| `src/renderer/components/EmbeddedWebview.tsx` | 组件 | **新建** — 可复用的内嵌 webview 组件(工具栏 + 错误处理 + webview |
| `src/renderer/styles/components/EmbeddedWebview.module.css` | 样式 | **新建** — webview 容器 / 工具栏 / URL 栏样式 |
| `src/renderer/components/pages/ClientPage.tsx` | 组件 | 替换 `openExternal``<EmbeddedWebview>` + cookie 同步 |
| `src/renderer/styles/components/ClientPage.module.css` | 样式 | `.page` 增加 flex 布局以支持 webview 撑满 |
---
## 关键设计决策
### 1. 使用 `<webview>` 而非 `BrowserView` 或 `<iframe>`
| 方案 | 优点 | 缺点 |
|------|------|------|
| `<webview>` | React JSX 内直接使用,生命周期由组件控制,共享 `defaultSession` | Electron 官方标记为"less secure"(但加载的是自有域名) |
| `BrowserView` | 进程隔离更彻底 | 需在 main 进程管理定位/尺寸,与 React 渲染模型脱节 |
| `<iframe>` | 最简单 | 受 CSP 限制,无法设置 httpOnly cookie跨域问题 |
**结论**`<webview>` 最适合本场景 — 加载自有域名、需要 cookie 同步、需要与 React 组件联动。
### 2. 使用 `defaultSession` 而非 `partition`
webview 默认使用 `defaultSession`,与 `session:setCookie` handler 操作同一 session。无需额外 partition 配置cookie 自然可见。
### 3. Cookie 属性选择
```typescript
{
httpOnly: true, // 防止 webview 内 JS 读取 token
// secure: 主进程根据 URL scheme 自动判断HTTPS→true, HTTP→false
// sameSite: 主进程自动判断secure 时设 no_restriction否则不设
// domain: 不设置 → host-only cookie与 webview 内 Set-Cookie 行为一致
}
```
- `httpOnly: true` — 安全性:即使 webview 加载的页面被注入 XSS也无法通过 `document.cookie` 读取 token
- `secure` — 由主进程根据 URL scheme 自动设置。HTTPS 域名必须为 trueChromium 要求 `SameSite=None` 需配合 `Secure`。HTTP 域名自动为 false
- `sameSite` — 主进程自动判断:`secure: true` 时设 `'no_restriction'`HTTP 场景不设
- **不设 `domain`** — host-only cookie确保与 webview 内登录的 `Set-Cookie` 能互相覆盖,避免 `count=2` 冲突v1.1 修复)
### 4. Cookie 双向覆盖
Electron 侧和 webview 内的 ticket cookie 使用相同属性host-only + httpOnly + secure可以互相覆盖
| 方向 | 行为 |
|------|------|
| **Electron → Webview** | `removeSameNameCookies()` 清除所有旧 cookie → 写 host-only cookie → webview 导航时携带 |
| **Webview → Electron** | webview 登录 `Set-Cookie: ticket=xxx`host-only→ 覆盖 Electron 写的同名 cookie |
| **Electron 再写** | `removeSameNameCookies()` 再次清除所有(含 webview 设的)→ 写新值 |
**修复前问题**Electron 显式设 `domain: "agent.qiming.com"` → Chromium 存为 `.agent.qiming.com`domain cookie而 webview 登录的 `Set-Cookie` 无 Domain 属性 → Chromium 存为 `agent.qiming.com`host-only cookie。两个 cookie 条目同名不同属性,`count=2` 无法互相覆盖。
### 5. Cookie 同步策略v1.3
**核心规则**:有有效 token → 覆盖 cookie有过期 token → 只清对应缓存并跳过;无 token → 不做任何操作。
- reg 接口返回 token 时,不管现有 cookie 是否存在、是否在有效期,都直接调用 `syncSessionCookie` 覆盖
- reg 接口未返回 token 时,跳过同步,不清空现有 cookie避免误清除 webview 内登录产生的 ticket
- 同步成功后清除 one-shot token`AUTH_TOKEN`),失败时保留以供重试
- **JWT 过期检查**v1.3):同步前检查 token 的 `exp` 字段,若已过期(超过 30s 宽限期)则:
- 只清除对应来源的缓存one-shot → `AUTH_TOKEN`domain cache → domain key
- 跳过 cookie 覆写,避免用过期 token 覆盖 webview 内重新登录获得的新 ticket
- 30s 宽限(`JWT_EXPIRY_BUFFER_MS`)容忍客户端与服务端时钟偏移
- **webview 重新登录后清缓存**v1.3`persistTicketCookie()` 在检测到 webview 登录成功后,清除 `AUTH_TOKEN` + domain key 双缓存,作为主修复;`syncCookieAndBuildUrl` 的 exp 检查作为防御性兜底
**v1.2 逻辑**(已更新):有 token → 无条件覆盖。v1.3 增加了过期检查,防止 webview 重新登录后旧缓存覆盖新 ticket。
---
## 布局结构
```
.app-container (100vh, flex column)
├── .app-header (48px, flex-shrink: 0)
└── .app-body (flex: 1, flex row, overflow: hidden)
├── .app-sider (140px)
└── .app-content (flex: 1, overflow-y: auto, padding: 20px 24px)
└── wrapper div (flex: 1, flex column, min-height: 0)
└── .page (flex: 1, flex column, min-height: 0)
├── [webviewVisible = true]
│ └── <EmbeddedWebview url={...} onClose={...}>
│ └── .container (flex: 1, flex column)
│ ├── .toolbar (固定高度, flex-shrink: 0)
│ │ ├── 返回按钮
│ │ ├── URL 显示 (.url, ellipsis)
│ │ └── 刷新按钮
│ ├── Alert (可选, 加载失败时显示)
│ └── <webview> (flex: 1, 撑满剩余空间)
└── [webviewVisible = false]
├── 依赖告警 (Alert)
├── 账号状态 (section)
├── 服务状态 (section)
└── 快捷操作 (section)
```
`.page` 添加了 `display: flex; flex-direction: column; flex: 1; min-height: 0` 以确保 webview 能撑满内容区。
---
## Webview 错误处理
`EmbeddedWebview` 组件在 `useEffect` 中注册事件监听,卸载时自动清除:
| 事件 | 处理 |
|------|------|
| `did-fail-load` | 当 `errorCode !== -3`(排除导航取消)时显示 Alert 错误提示 |
| `did-start-loading` | 清除之前的错误状态 |
错误以 `<Alert type="error" closable>` 形式显示在工具栏下方,用户可手动关闭或等待下次加载自动清除。
---
## IPC 接口
### `session:setCookie`
**方向**Renderer → Main
**参数**
```typescript
{
url: string; // cookie 关联的 URLe.g. "https://agent.qiming.com"
name: string; // cookie 名称e.g. "ticket"
value: string; // cookie 值token
// domain 不设置 → host-only cookie与 webview Set-Cookie 行为一致
httpOnly?: boolean; // 默认 true
// secure 不设置 → 主进程根据 URL scheme 自动判断HTTPS→true, HTTP→false
}
```
**返回**
```typescript
{ success: boolean; error?: string }
```
**实现**
1. `removeSameNameCookies()` — 清除所有同名旧 cookie含 domain cookie 和 host-only cookie
2. `electronSession.defaultSession.cookies.set()` — 写新 cookie
3.`secure: true` 时自动设置 `sameSite: 'no_restriction'`
---
## 安全考量
| 风险 | 缓解措施 |
|------|---------|
| webview 加载恶意内容 | 仅加载用户自己配置的 domain`authState.domain`),非任意 URL |
| Token 泄露 | httpOnly cookie 防止 JS 读取token 存 SQLite 与其他凭证同等安全 |
| `session:setCookie` 被滥用 | `contextIsolation: true` + preload 白名单,仅 `electronAPI.session.setCookie` 可调用 |
| `webviewTag: true` 安全性 | Electron 官方建议谨慎使用,但本场景加载可信域名,风险可控 |
| `allowpopups` | 允许页面打开弹窗(如 OAuth弹窗共享 `defaultSession`,可信域名下可接受 |
---
## 相关文档
- [认证机制与 SavedKey 生命周期](./auth-savedkey-lifecycle.md) — savedKey / configKey 设计
- [服务启动与 Reg 同步](../../CLAUDE.md#service-startup--reg-sync) — reg 接口调用时序
---
*本文档由架构维护者负责更新*