chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -0,0 +1,340 @@
---
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 接口调用时序
---
*本文档由架构维护者负责更新*