Files
WebDrop/docs/frontend.md
T

199 lines
14 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. 技术栈
| 项 | 选择 | 说明 |
|---|---|---|
| 构建 | 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 密钥;仅同步 link/ownerLink、草稿清单、邀请私钥和移除墓碑;不含终端 S、设备名、缓存或文件 Blob。按账号/访客分库,会话在 Worker 中使用,通过本机 IndexedDB 恢复 |
| thread 与虚拟文件夹 | `@webdrop/core` 用共享结构编码 `thread_id`/`folder_id` 和 CONTROL;mode 只切换是否加密;worker 解析控制正文,store 保留最新位置以防旧条目更新回退 |
| 条目预览在沙箱 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` 未就绪前,`core-bridge/` 按 protocol.md 定义的接口先放适配层和假实现,真实实现就绪后替换,组件与 store 不改。
## 12. 实际边界与会话
core-bridge/instance.ts 使用真实 Worker 实现,mock 仅用于原型和测试。账号与设备由 worker/account.ts 编排;账号密钥包通过 accountChannels.ts 与 channelGrant.ts 领取和核对;频道成员/设置/设备席位通过 channelResources.ts 调用 API;thread/folder/file.published 共用 channelThreads.ts 的加密 CONTROL 日志。
@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;界面频道对应 ThreadInfo 和 thread_id。ChannelGroupInfo 属于当前空间,ThreadInfo.groupId 可选。store 从 group-created/group-renamed/thread-grouped 事件重建分组;threadGroups 保留最新归属,避免延迟的创建事件将频道移回旧分组。分组不是文件夹,也不是成员/密钥边界。
## 15. 文件交互边界
FileExplorer 编排选择、菜单、拖放和目录导航,ExplorerFileRow 只订阅单个文件。选择范围与拖放封装在 lib/explorer.ts 中做纯逻辑校验;拖放数据仅含随机文件 ID 和当前空间/频道标识,不包含链接、密钥或文件内容。移动、删除、下载均经上层回调和 store 完成。
ContextMenu 是无业务 UI,负责 portal、视口限制、键盘焦点和关闭。悬停预览由独立组件挂载,受限文件只渲染元数据。文件详情的显式解密流程继续沿用原有认证和下载计数。
## 16. 文件聊天消息与身份快照
ItemBase.author 为可选发送者快照 {username,displayName,avatar?}。文件的快照放入已有 file.published 加密目录记录,文本通过 item.author CONTROL 记录关联。store 的 authors 索引支持身份记录先于条目到达,并在晚到的下载/进度更新中保留身份,避免取文件内容后丢失头像。该快照用于展示,不代表逐账号数字签名。
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 时删除。旧版保存的会话视为开启自动恢复,但没有原始密码,不伪造密码填充。