17 KiB
version, last-updated, status
| version | last-updated | status |
|---|---|---|
| 1.3 | 2026-04-09 | 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_TOKEN,domain_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 属性选择
{
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读取 tokensecure— 由主进程根据 URL scheme 自动设置。HTTPS 域名必须为 true(Chromium 要求SameSite=None需配合Secure)。HTTP 域名自动为 falsesameSite— 主进程自动判断: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)容忍客户端与服务端时钟偏移
- 只清除对应来源的缓存(one-shot →
- 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
参数:
{
url: string; // cookie 关联的 URL(e.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)
}
返回:
{ success: boolean; error?: string }
实现:
removeSameNameCookies()— 清除所有同名旧 cookie(含 domain cookie 和 host-only cookie)electronSession.defaultSession.cookies.set()— 写新 cookie- 当
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 生命周期 — savedKey / configKey 设计
- 服务启动与 Reg 同步 — reg 接口调用时序
本文档由架构维护者负责更新