--- version: 1.3 last-updated: 2026-04-09 status: stable --- # 内嵌 Webview 会话浏览器 + Cookie 登录态同步 > 本文档描述将会话页面从系统浏览器迁移至客户端内嵌 webview 的方案,以及通过 reg 接口返回的 `token` 自动同步 httpOnly cookie 实现免登录打通的设计。 --- ## 背景与动机 | 改动前 | 改动后 | |--------|--------| | 点击「开始会话」调用 `shell.openExternal` 在系统浏览器打开 | 在 ClientPage 内嵌 `` 渲染会话页面 | | 用户需要在外部浏览器重新登录 | 通过 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) │ │ │ │ │ │ │ │ │ │ │ │ └── 使用 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) │ └── 发起请求 ├── 请求自动携带 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` | 类型 | **新建** — `` 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` 为 `` + cookie 同步 | | `src/renderer/styles/components/ClientPage.module.css` | 样式 | `.page` 增加 flex 布局以支持 webview 撑满 | --- ## 关键设计决策 ### 1. 使用 `` 而非 `BrowserView` 或 `