Files
WebDrop/docs/frontend.md
T
justme 58506f82de
ci/woodpecker/pr/web-build Pipeline failed
ci/woodpecker/pr/ci Pipeline was successful
docs: describe workspace signing boundaries
2026-10-14 04:06:29 +08:00

207 lines
15 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.
# 前端架构
`web/` 的工程文档。视觉与交互见 [ui-design.md](ui-design.md),协议见 [protocol.md](protocol.md),接口见 [api.md](api.md)。
## 1. 技术栈
### 数据来源与契约
HTTP 请求和响应类型由后端 DTO 生成到 `core-bridge/generated/api.ts`,不手写第二份接口模型。
运行 `dotnet run --project tools/ContractGenerator -- <仓库目录>` 更新;`scripts/check-api-contract.mjs` 检查生成物是否与后端来源一致。
`/info` 下发创建默认值、可选设置和部署限制,创建与设置页面通过 store 读取,不在组件里定义业务默认值。
在线空间与分享列表从服务端刷新元数据;IndexedDB 作为离线缓存、凭据与发送队列。网络故障可显示缓存,认证或契约错误不能当作离线成功。
账户资料、频道、分组、目录和资源归属通过后端接口加载及修改。加密资源元数据使用独立协议模型,不序列化 UI 的进度、预览、选中或历史版本状态。Worker 解密资源,store 保存服务端结构和资源投影,组件渲染。
| 项 | 选择 | 说明 |
|---|---|---|
| 构建 | Vite | 纯客户端 SPA,不做 SSR |
| 语言 | TypeScript,`strict` + `noUncheckedIndexedAccess` | |
| UI | React 19.3(稳定版,含 `ViewTransition`、`Activity`) | 不使用 canary |
| 状态 | Zustand | 按领域拆 store |
| 本地存储 | `idb`(IndexedDB) | 发送队列、「我的频道」、账号资源工作副本、草稿预览所需的本机文件 |
| 样式 | 原生 CSS + CSS 变量 + CSS Modules | 无 Tailwind、无 CSS-in-JS |
| 路由 | 自写极简导航(`app/navigation.ts`) | 只有两三个路由,且需统一挂 view transition |
| 测试 | Vitest(单元)、Playwright(端到端) | |
| 二维码生成 | 自写编码器 `lib/qr.ts`(字节模式、纠错 M、版本 1–10,最多 213 字节),与 Python `qrcode` 逐模块对比验证,见 `lib/qr.test.ts` |
| 扫码 | 浏览器自带 `BarcodeDetector` + `getUserMedia`,零依赖;不支持时退回粘贴链接 |
| 加解密 | `@webdrop/core`,经 `core-bridge` 调用,在 Worker 中运行 | |
**依赖策略**:新增运行时依赖需要书面理由(解决什么、为何不能自写、体积)。不引入 UI 组件库、动画库、图标库、日期库。依赖越少,越容易审计;前端是整个方案的信任根。
生产构建将 `web/package.json` 的版本、前后端各自的 commit/tag 和 UTC 构建时间编译进前端,首页右下角将时间换算为 Asia/Shanghai 并格式化为 `yyyy-MM-dd HH:mm`。部署构建通过 `WEBDROP_FRONTEND_COMMIT`、`WEBDROP_FRONTEND_TAG`、`WEBDROP_BACKEND_COMMIT`、`WEBDROP_BACKEND_TAG`、`WEBDROP_BUILD_TIME` 提供元数据;本地构建没有 CI 环境时尝试读取 Git,时间取本次构建时间。
## 2. 目录与分层
```
web/src/
├─ app/ 入口、Provider、导航、主题
├─ styles/ tokens.css(唯一的颜色来源)、base.css
├─ ui/ 无业务的基础组件、图标(Button、Sheet、Menu、Toast)
├─ features/ 按功能垂直切分:home / channel / join / item / settings
├─ store/ 领域状态与事件流 reducer(纯逻辑)
├─ core-bridge/ 对 @webdrop/core、Worker、网络传输的封装
└─ lib/ 与框架无关的小工具(链接解析、退避、格式化)
```
依赖方向**单向向下**:
```
app → features → ui
│
└──→ store → core-bridge → @webdrop/core
lib 可被任何层使用,lib 不依赖其他层
```
| 规则 | 说明 |
|---|---|
| `ui/` 不依赖 `features/`、`store/` | 基础组件无业务 |
| `features/` 之间不互相导入 | 需共享就上移到 `ui/` 或 `store/`;唯一例外:`channel/`、`home/` 是组合层,可导入 `item/`、`scan/`、`channel/` 里的弹层 |
| 组件不直接调用网络或加解密 | 只通过 `store/` 的 action 与 selector |
| 协议逻辑只在 `store/`、`core-bridge/`、`lib/` | 全部用纯函数,可脱离 DOM 测试 |
| 不使用 barrel(`index.ts` 汇总导出) | 直接从具体文件导入,利于 tree-shaking 与追踪 |
## 3. 状态模型
频道是追加日志,**条目状态由日志推导,不单独存储可变副本**。
| store | 持有 | 说明 |
|---|---|---|
| `channels` | 本机已保存的频道、当前会话的频道元信息 | 持久化到 IndexedDB |
| `items`(按频道) | 由帧装配出的条目 Map(`item_id → Item`) | 按 `item_id` 建 Map,不用数组 `find` |
| `transport` | 连接状态、`last_seq`、降级级别 | |
| `queue` | 待发送帧(已加密的字节) | 持久化到 IndexedDB |
条目用判别联合,所有 `switch` 必须穷尽:
```ts
type ItemState =
| { kind: 'receiving' }
| { kind: 'complete' }
| { kind: 'incomplete' }
| { kind: 'tampered' }
| { kind: 'removed'; reason: 'deleted' | 'expired' | 'evicted' | 'limit' };
```
组件订阅**派生出的最小值**(如 `isOnline`、某条目的 `state.kind`),不订阅整个 store。
## 4. 传输与重连
遵循 [api.md §降级与重连](api.md):WebSocket → SSE → 长轮询;心跳 20 秒,45 秒无消息判死;退避 1–30 秒加抖动;重连携带 `from=last_seq+1`;收到 `410` 则**从 `from=0` 全量同步**,并丢弃本地已不存在的条目(从 `oldest_seq` 读会漏掉 seq 更小的存活条目,见 api.md)。
- 传输层是 `core-bridge/` 内的状态机,对 `store/` 只暴露「记录流」和「连接状态」。
- 上传永远走 HTTP `POST`,依赖协议幂等重发,不需要前端去重逻辑之外的补偿。
- **队列里持久化的是已加密的帧**。重试必须发送完全相同的字节(protocol §5 的 IV 规则),前端任何位置都不得对同一 `(item_id, frame_index)` 重新加密。
## 5. 密钥与安全处理
| 规则 | 说明 |
|---|---|
| 取出 fragment 后立即 `history.replaceState` | 在任何异步代码之前完成 |
| K 导入为不可导出的 `CryptoKey` | 内存中不保留原始字节 |
| 降级防御 | 链接含 `k` 就只接受 enc 且认证通过的帧 |
| 不把密钥、令牌、明文写入 `console`、错误上报、URL、`localStorage` | 「我的频道」只存 IndexedDB |
| 便捷分享草稿预览 | 原始文件只缓存在创建者本机 IndexedDB,不会随文件帧上传;移除条目或封存时清理,用于刷新后继续预览草稿 |
| 账号资源保险库 | Worker 派生登录凭据与 AES-GCM 密钥;同步资源访问凭据、邀请私钥和移除墓碑;不含个人资料、草稿内容、终端 S、设备名或 Blob。按账号/访客分库,会话通过本机 IndexedDB 恢复 |
| 频道与文件夹 | 后端持久化结构和资源归属,Worker 读取结构快照;加密文件元数据中的原始位置用于内容校验,UI 使用后端当前归属 |
| 条目预览在沙箱 iframe 渲染;raw 内容只当纯文本显示或下载 | 无内联渲染,不使用 `dangerouslySetInnerHTML` |
| 严格 CSP,所有脚本样式带 SRI,不使用内联脚本 | 与 architecture §5 一致 |
| 不引入任何第三方脚本、统计、字体 CDN | 字体自托管 |
## 6. 路由与导航
- 路由只有:`/`、`/c/:id`、`/c/:id/settings`。
- **所有跳转经 `app/navigation.ts` 的 `navigate(to, direction)`**,内部用 `startTransition` + `addTransitionType` 触发 view transition。禁止在组件中直接 `history.pushState` 或 `location.assign`。
- 浏览器后退/前进不会触发 view transition(`popstate` 同步),这是已知限制,接受其无动画。
- 页面级方向性 `ViewTransition` 写在**页面组件**里,不写在布局里;用一个共享的 `DirectionalTransition` 包装。
## 7. 动画实现要点
遵循 `react-view-transitions` 技能的约束,并与 [ui-design.md §9](ui-design.md) 一致:
- 动画 CSS 取自技能的配方(`--duration-*`、`fade`、`slide`、`nav-forward/back`),集中放在 `styles/transitions.css`,不在组件里写动画 CSS。
- `ViewTransition` 一律 `default="none"`,显式列出 `enter` / `exit`,成对出现。
- Suspense 的揭示动画用简单字符串属性,不用类型映射。
- 共享元素的 `name` 全局唯一(如 `item-${id}`),不在可同时挂载两处的复用组件里写具名 VT。
## 8. 性能约定
遵循 `react-best-practices` 技能中适用于纯客户端 SPA 的部分:
| 方面 | 约定 |
|---|---|
| 首屏体积 | 按路由 `lazy` 拆分;二维码、预览、设置等重组件动态加载;**禁止 barrel 导入** |
| 重渲染 | 订阅最小派生值;事件处理中才用的状态用 ref 或 `getState()`;高频值(上传进度)用 ref 或独立细粒度 store,不触发整列表重渲染 |
| 派生状态 | 渲染期计算,不用 effect 同步 |
| 列表 | `content-visibility: auto`;条目 key 为 `item_id` |
| 条件渲染 | 用三元,不用 `&&` 渲染可能为 `0` 的值 |
| 重计算 | 加解密、装配、哈希一律在 Worker,主线程不处理大文件 |
| 非紧急更新 | 用 `startTransition` / `useDeferredValue` |
| 模块级单例 | 只做一次的初始化(Worker、IndexedDB 连接)用惰性初始化,避免重复 |
`server-*` 与 Next.js 相关规则不适用本项目。
## 9. 可维护性约定
1. 一个文件一个职责;组件文件约 150 行为上限,超出就拆。
2. 不预先抽象。同样的代码第二次出现才抽,第三次必须抽。
3. 导出面最小化:不被其他文件使用的符号不导出。
4. 注释只写「为什么」,尤其是协议约束与安全理由,并指向文档章节(如 `// protocol §5: 重试必须发相同字节`)。
5. 颜色、间距、圆角、阴影都不得写死在组件里,一律走 `tokens.css`。
6. 改动协议相关行为前,先改 `docs/`,再改代码(与 architecture §7 一致)。
7. 每个 `store/` 与 `lib/` 的模块有对应的 Vitest;每个用户可见流程有 Playwright 用例。
## 10. 测试
| 层 | 工具 | 内容 |
|---|---|---|
| `lib/`、`store/` | Vitest | 条目装配、状态推导、退避、队列、链接解析(含降级防御的负例) |
| `core-bridge/` | Vitest | 传输降级、`410` 重同步、断线续传(用假传输) |
| 界面流程 | Playwright | 双浏览器内容一致;断网恢复后无丢失无重复;抓包无明文和 K |
| 视觉 | Playwright 截图 | 亮暗两套、手机与桌面宽度、reduced-motion、无 `backdrop-filter` 回退 |
## 11. 阶段(对应 architecture §7)
| 阶段 | 前端内容 |
|---|---|
| 3 | 设计系统与骨架(静态,假数据) → 创建、加入、文本与 JSON 实时同步、重连、发送队列、「我的频道」 |
| 4 | 文件:流式上传下载、GATED 拉取、下载计数、预览、粘贴与拖放 |
| 6 | PWA、Web Share Target、二维码与短配对码 |
`@webdrop/core` 与真实 Worker 已接入,原型假数据与模拟连接实现已移除。接口测试使用测试替身,不保留第二套业务实现。
## 12. 实际边界与会话
core-bridge/instance.ts 使用真实 Worker 实现。账号、设备和个人资料由 worker/account.ts 与 accountProfile.ts 编排;账号密钥包通过 accountChannels.ts 与 channelGrant.ts 领取和核对;空间成员/设置/设备席位通过 channelResources.ts 调用 API;频道、分组和目录由 channelThreads.ts 调用后端。workspaceRecords.ts 只读取加密文件元数据,不处理账户或结构操作。
@webdrop/core 仅实现纯密码学与帧协议,不访问数据库或网络。sessions.ts 管本机已解锁会话。transfer.ts 管持久化密文发送队列,队列按资源/创建顺序索引,确保文件密文帧先于目录记录上传。downloadStorage.ts 优先使用 OPFS 逐块写入临时文件,认证结束后交付;不支持时最多 64 MiB 的 Blob 回退。
空间和 Share 各有独立领域 API 与生命周期;Share 封存由服务端事务清除 owner_hash,不能再写。账号保险库同步邀请私钥、资源凭据和移除墓碑;个人资料从后端读取,设备 S、文件缓存与 outbox 保留本机。
## 13. 账号会话恢复与频道密钥领取
本机 IndexedDB 保存账号 session token、不可导出的保险库 CryptoKey 和最近验证的加密保险库,不保存密码或登录 verifier。启动时先恢复账号并验证服务端 session,再读取该账号的资源数据库;网络离线保留本机身份,服务端明确拒绝 session 时清除登录凭据。退出登录清除本机自动恢复记录。记住登录允许使用本浏览器的人访问账号资源。
登录/同步后按账号领取 /accounts/channels 密钥包,解密并核对频道 ID、模式和角色,再补齐本机频道;本机主动移除的资源墓碑不会被旧密钥包重新加入。新设备不复用设备 S。打开无 fragment 的频道地址时,已登录账号可以领取授权包,待接受邀请需用户确认加入。未授权时显示需要频道主邀请,不再只要求含密钥链接。
## 14. 用户概念映射与分组
界面 Space 对应既有顶层 ChannelInfo 与 /c/:id;界面频道对应后端 WorkspaceChannel 和帧协议 threadId。store 直接渲染服务端结构快照,不重放创建/重命名/分组事件。ThreadInfo 与 FolderInfo 只将后端可空字段转成 UI 的可选字段。分组不是成员或密钥边界。
## 15. 文件交互边界
FileExplorer 编排选择、菜单、拖放和目录导航,ExplorerFileRow 只订阅单个文件。选择范围与拖放封装在 lib/explorer.ts 中做纯逻辑校验;拖放数据仅含随机文件 ID 和当前空间/频道标识,不包含链接、密钥或文件内容。移动、删除、下载均经上层回调和 store 完成。
ContextMenu 是无业务 UI,负责 portal、视口限制、键盘焦点和关闭。悬停预览由独立组件挂载,受限文件只渲染元数据。文件详情的显式解密流程继续沿用原有认证和下载计数。
## 16. 文件聊天消息与身份快照
ItemBase.author 由服务端资源作者和账号资料投影得到,晚到的解密内容不能覆盖服务器提供的作者与位置。客户端提供的作者声明不能覆盖服务端资料。这是服务端身份归属,不是端对端逐账号签名。
ChatPane 订阅当前频道的条目 ID,ChatMessage 单独订阅条目状态和上传进度;文件目录变成文件消息,不重复创建独立文本通知。预览/下载继续经已有 store action,移动文件不会重新上传。GATED 内容只有明确操作后才读取。
## 17. 本机凭据保存
accountPersistence 新增按 API endpoint 隔离的 credentials store。用户名和密码使用随机 IV 的 AES-GCM 密文保存;不可导出的 CryptoKey 通过 IndexedDB structured clone 保存在本机。只有登录表单读取时才在 Worker 解密并填充,不写入 localStorage、URL 或日志,不上传原始密码。加密存储不能防止已被控制的同源前端或终端。
AccountSession.remember 控制会话落盘,所有同步、资料更新与登录保存路径统一检查该标记。关闭记住或清除本机保存后,不能被轮询重新保存。正常退出保留已保存填充信息,明确清除或服务端拒绝 session 时删除。旧版保存的会话视为开启自动恢复,但没有原始密码,不伪造密码填充。