Files
reader/docs/architecture.md
T
hectorqinandClaude Opus 5.5 120827bad6 docs: bring every document in line with the code
Audit of README, docs/, android/README.md, android/contract.md and CLAUDE.md
against the current server, web and Android code: V3 navigation and settings
locations, Docker Hub release images, GitHub clone URL and CI, missing API
endpoints and error codes, architecture of lazy chunks/themes/sources, media
pages and routes, the Android bridge contract, and the new file-management and
member-upload permissions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 02:03:45 +00:00

862 lines
55 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.
# 架构与数据边界
本文记录**为什么这样设计**,尤其是那些一旦写错就很难回头的决策。
早期产品设计中的约束在这里落成了实现,逐条对应;当前行为以代码和 API 文档为准。
影音采用独立数据库和服务线程,详见[影音架构](media-implementation.md)及[写入隔离](media-write-isolation.md)。
---
## 1. 数据边界
| 位置 | 内容 | 读写 |
| --- | --- | --- |
| `BOOKS_DIR` (`/books`) | 用户自己的电子书;管理员可执行文件管理操作,成员只能上传书籍 | 按挂载权限读写 |
| `DATA_DIR` (`/data`) | SQLite、封面缓存、扫描记录、上传暂存、令牌密钥和影音数据 | 读写 |
| 客户端本地 | 离线书文件缓存、本地进度 | 读写 |
服务端是唯一真相源。客户端只承担「渲染 + 缓存」。
### 数据目录边界
`src/config/index.ts` 在启动时断言 `DATA_DIR` 不在 `BOOKS_DIR` 内,否则拒绝启动。这是运行时硬约束。
`BOOKS_DIR` 是否只读由部署挂载权限决定:只读挂载适合浏览和阅读;可写挂载允许管理员文件管理器上传、改名、移动、删除和新建目录;成员只能在书库浏览页上传书籍文件(只收书籍格式、不解包、不覆盖),且可由业务设置 `library.memberUpload` 整体关闭,改动已有文件的端点都要求管理员(`requireAdmin`,见 [API](api.md#书库文件管理器))。
扫描器与所有文件访问都经过 `src/lib/paths.ts` 的 `resolveInside()`——
唯一的路径解析出口,`../` 逃逸会抛 `PATH_TRAVERSAL`。
扫描时也**不跟随符号链接**:指向挂载点外的软链会读到用户没打算共享的文件。
封面缓存写在 `DATA_DIR/covers/`,不写在书旁边;服务端也不会创建 `.calibre` 或其它书旁元数据文件。
---
## 2. 书籍主键
```ts
id = sha256( (dc:identifier ?? '').toLowerCase() + '\0' + sha256(fileBytes) )
```
### 为什么不用文件路径
用户的真实行为是**持续整理书库**:改名、分目录、加前缀。
| 场景 | 路径做主键 | 当前设计 |
| --- | --- | --- |
| 重命名 `三体.epub` → `刘慈欣 - 三体 (修订版).epub` | 进度全丢 | 进度保留 |
| 移动到另一个目录 | 进度全丢 | 进度保留 |
| 同一本书放两份 | 书架出现两条 | 合并成一条 |
| 重新编码的同名文件 | 误认为同一本 | 视为不同副本 |
第二列是自建书库产品最常见的差评来源,所以这条必须在上线前定死。
### 为什么哈希也要参与
只用 `dc:identifier` 不行:很多自制/盗版 EPUB 没有 identifier,或者多本书共用
一个占位 identifier。只用哈希也不行:同一本书不同来源的副本哈希不同,无法合并。
两者结合:identifier 提供稳定的锚点,哈希保证「字节不同就是不同的副本」。
`identifier` 缺失时退化为纯哈希,行为仍然正确(只是无法跨副本合并)。
### 文件表与书表的分离
`book_files` 记录磁盘上的文件(`rel_path`、`size`、`mtime`),`books` 记录书。
多对一:一本书可以有多份副本。
文件被删时 `missing=1`,不立即删记录——用户可能只是临时拔了移动硬盘。
但一旦这本书已经有了别的可用文件,旧的行就是纯噪声,会被清掉,
否则反复整理书库会让文件表无限增长。
只有「没有任何可用文件」且「用户没手动加进书架」的书才会被删除。
宁可显示一本暂时不可用的书,也不能删掉读者精心整理过的书架条目。
---
## 3. 增量扫描
两阶段变更检测(`Scanner.indexFile`):
1. **`mtime + size`**:便宜,跳过绝大多数文件
2. **内容哈希**:权威,确认字节真的变了
只有第一阶段判断"可能变了"才读文件算哈希。所以每 60 秒轮询一次(默认值,
管理员可在「系统设置 → 服务配置」里改扫描间隔)也很轻。
`Scanner.scan()` 对并发调用做去重(共享同一个 Promise),
避免定时器和手动触发同时砸磁盘。
---
## 4. 元数据优先级链
```
1. 手动修改(metadata_overrides 表)
2. EPUB 内嵌(dc:* + calibre meta)
3. provider 补丁(只填空缺)
4. 文件名解析
```
### 关键实现细节
`books` 表存的是第 2/3/4 层的结果。第 1 层单独一张表,**读取时**叠加
(`ShelfService.toDto` → `applyOverrides`)。
这样做的原因:扫描器可以放心地刷新 `books` 表,因为手动修改根本不在那张表里,
不可能被覆盖。
`books.source` 记录的是**基础值的来源**(`embedded` / `filename` / `provider:x`),
不记录 `manual`。手动修改过的字段由 DTO 的 `manualFields` 单独返回。
保持来源干净,撤销单个字段时才能正确回落到原始值。
### 文件名解析的保守策略
中文电子书里 `书名 - 作者` 和 `作者 - 书名` 都极常见,没有标记时**无法区分**。
因此只在有证据时才切分:
- `《书名》` → 这一侧是书名
- `著` / `编` / `译` → 这一侧是作者
- 一侧是中文人名(2-4 字且首字为常见姓氏)→ 另一侧是书名
- 一侧是拉丁文人名 → 另一侧是书名
- **都不满足 → 整串保留为书名,作者留空**
最后一条是刻意的:漏一个作者,读者手动补一下就好;猜错一个作者,
书就被归到错误的人名下,而且他未必会发现。
其余正则也踩过坑,值得记下来:
- 系列正则 `[【\[]...[】\]]` 里 `\[` 在字符类中不转义,会把分隔符 `' - '` 吃掉,
导致 `金庸 - 射雕英雄传` 变成 `title=金庸, author=射雕英雄传`。改成显式括号对匹配。
- 「(第1卷)」这种卷标,书名是作品本身,不是「第」。要先剥离再切分作者,
且剥离要按完整括号组做,否则会留下 `射雕英雄传(第1卷` 这样的半截括号。
---
## 5. 同步与冲突合并
### 为什么是 last-writer-wins
一台设备属于一个读者。多设备之间不存在"编辑冲突",只存在"某台设备落后了"。
所以 LWW 是正确语义,不需要 CRDT 或向量时钟的复杂度。
### 墓碑
笔记删除写 `deleted=1` 而不是 `DELETE`。
否则:手机离线时删了一条笔记 → 平板(也离线,数据更旧)重连 → 把笔记推回来 →
已删除的笔记复活。墓碑让这个时序变得无害。
### 批次原子性
`SyncService.push` 整个批在**一个事务**里。断网不会留下写了一半的进度。
### 「最近阅读」是书架的一个排序,不是第二排封面
`SyncService.recentlyRead` 注入 `ShelfService`,把书行的组装交给它,自己只负责
「按最近阅读时间排序」和那两个进度字段。
理由是一次已经发生过的漂移。#40 的「书架上点击继续阅读,提示书本不在书架上」
的根因就在这条缝里,而且是两层:
- **可见性判据**曾经是 `hidden = 0` 就完了,而书架还要求「存在一个未丢失的文件」。
于是一本文件被删掉、或唯一副本在没插上的移动盘上的书,仍然被画在「继续阅读」
这一行里,点开必然 404 —— 那句话是真的,错的是那张卡片,而它正画在同一屏
「共 N 本」的下面。修法是把判据做成同一句话,而不是教客户端先查一遍:
先查一遍只会把「读者点不开的书」修成「读者看不见的书」。
- **字段形状**:端点返回进度行自己的命名(`bookId` / `updatedAt`),而客户端的
`ContinueReadingItem extends Book` 读 `id`。`title` 和 `coverUrl` 恰好两种拼法
同名,所以卡片画得好好的,点下去却把 `{ id: undefined }` 交给路由——
`#/book/undefined`、404、然后被报成「这本书不在书架上了」。
两个手写类型之间的字段名漂移,中间没有任何一个请求里出现过正确的那个名字。
一条规则:**一个屏幕上的同一本书,只能是同一个对象。** 所以书行只在
`ShelfService` 里组装一次(`toDto` / `getMany`),其它接口要书行就调它;
「最近更新」被覆盖层影响,卡片也得跟着变。
**那条「继续阅读」横排本身已经没有了**,而它消失的方式正是上面这条规则的另一半:
十张卡片画的是下面网格里已经有的那十本。现在同一个 `/library/continue` 端点还在被调,
但它只是**排序键**——`recent` 这个顺序需要 `lastReadAt`,而那不在 `BookDto` 上。
于是「同一本书只有一个对象」推进了一步:同一本书在一屏上也只有**一张封面**,
而“我读到哪了”由那张封面自己的进度条回答。
### 「在不在书架上」只有一句话,两个屏幕都念它
一次报告(#40「补全完善书架的加入、移除逻辑」)把同一个问题从两端问了一遍,
两端答得不一样,而且**各自看起来都对**:
- **服务端**:`/books` 说「`user_books.hidden = 0` **且**存在一个未丢失的文件」,
`/library/browse` 的 `shelfState` 只读 `hidden = 0`。一行 `hidden = 0`、
文件掉了的书于是**同时**是「不在书架上」(书架)和「在书架上」(书库文件页)。
把这两种状态分开描述,等于让两个屏幕各自说一半真话,而读者没有任何办法知道
哪一半是旧的。
现在 `shelfState` 走的是同一对条件,`'off'` 就是「书架上没有它」。
- **客户端**:书库浏览页和书架页各自实现了一遍「书 ↔ 文件」的 join(按文件主名匹配),
以及各自一套动作文案。两份实现就是两次漂移机会,而漂移的形态是**同一屏上
同一本书两个名字**——这不是假设,这个 bug 在「最近阅读」那条缝里已经发生过一次
(§5)。
那个 join 后来被**整个删掉**了,而且它应该更早就被删掉:它不是一个实现得不够好的
join,它是一个**不需要存在**的 join。卡片手里有 `book.id`,服务端的 `book_files`
早就知道这个 id 的文件在哪,客户端却拿**书名**去和文件名对——而书名不等于文件名是
**常态**(扫描器从文件里读书名),列表还是分页的。「找不到「xxx」在磁盘上的路径」
就是这样来的。写入现在带 id(`docs/api.md` §`/library/browse/shelf`),
两个方向的措辞留在 `web/src/ui/shelf-membership.ts`,两个屏幕都调它,
而「书 ↔ 文件」的连接**不在客户端**。
这条值得当成一条规则:**客户端不应该重建服务端已经知道的事实。**
一个 join 只要有一端是猜的,它的失败就长得像「这本书不存在」,而不是像
「这个 join 猜错了」——读者拿到的是一句关于他自己书架的错误陈述。
**为什么「写完之后要重读」是这条规则的必然结果。** `shelfState` 是**响应字段**,
不是客户端的记忆:书库页写入之后如果只弹一句报告,卡片上的「加入书架」按钮就还在,
而它的下一次点击什么都不会发生。写入路径因此在同一个 `try` 里先重读、再报告——
顺序不能反,反了就是「重读把报告覆盖掉」的那个老问题(§5 里 `manager-status`
已经为此改过一次)。
**「移除」这件事为什么必须在书架那一屏。** 书库**曾经**能下架,但当时书库是**管理员那一半**,
成员打不开(现在书库浏览对所有人开放,只有 `#/library/files` 文件管理要求管理员)。
只有书库能下架,等于只有管理员能整理自己的书架。
所以书架上每张封面自己带一个「⋯」,写入前用一个确认框说清「磁盘上的文件一个都不会动」——
那句话才是读者真正在问的。`docs/ui.md` §3.5.2/§3.5.3 写的是它的形状与命中区。
**两个方向现在只在读者那两屏上,而文件页只剩「写盘」。** #40 的下一轮把文件页的
「从书架拿掉」拿掉了,理由不是位置而是**语义**:文件页上其他每一个控件都改挂载盘,
而 `下架` 改的是一行 `user_books`、一个文件都不动。它因此是那个菜单里唯一后果和页面
承诺对不上的条目,而它挨着的就是「删除」。`docs/ui.md` §3.5.1。
**浏览页问的是索引,不是读者自己的书架。** 它原来读 `/books`(也就是书架),
于是返回的每一本都已经在书架上——这一页存在的那个动作(「加入书架」)**永远画不出来**:
一个商城只陈列你已经买过的东西。现在它读 `scope=library`(`docs/api.md`),
每一项带 `shelfState`,于是「这一页有 N 本还不在书架上」和封面上的角标说的是同一件事。
**离线镜像是缓存,所以「少了一本书」它自己推不出来。** `offline.replaceBooks`
只会**加**:它拿到的是一页书,它没有任何办法知道「这一页里没有的那本书」是读者刚拿掉的、
还是这一次没请求到。于是书架在**冷启动的第一帧**画的是 `offline.books()`,
而那一帧里被拿掉的书还在——在线时下一帧会被服务端的答案替换掉,离线时永远不会。
所以移除路径显式调一次 `offline.removeBooks(result.books)`,而且是在 `refresh`**之后**:
放在前面只有在「服务端已经不再返回这本书」时才正确,而那正好是把镜像写成
「一个带 bug 的缓存」的那个假设。`result.books` 是服务端自己说它改过的那几本,
两边都是它说了算。
### 增量拉取
`GET /api/v1/sync?since=<上次的 serverTime>`。客户端记住上次返回的 `serverTime`,
每次只拿增量。移动网络下重连很便宜。
### 一次写入只发一个请求
`POST /api/v1/sync` 的响应**就是合并后的状态**(服务端在两个接口上返回同一份
`pull`)。所以推完之后**不要再拉一次**——`SyncEngine` 直接拿 push 的返回值喂给
`applyPulled`,没有东西可推(空闲轮询)时才真的发 `GET`。
之前不是这样:push 的返回值被丢掉,然后为了同一份数据再发一个 `GET`。于是读者每
翻一页都发一个 POST **加** 一个 GET,网络面板里就是成对出现的 `sync` ——这正是
「sync 接口在不停重复」的来源。
### 分页的位置:服务端切页,客户端算不了
两个列表的 `total` 和「这一页是哪些行」必须是**同一次行走**的结果。分开算
(第二次 `readdir`、或者一个缓存的计数)会让摘要、分页器和它下面那几行在任何一次
磁盘变化之后互相矛盾,而读者没有办法判断三个数字里哪个是旧的。所以
`BrowseService.list` 一次走完整个目录,用遍历的结果同时产出计数和这一页。
书库的分页上界(`BROWSE_PAGE_SIZE_MAX`)是刻意的:一个目录可以是文件系统里的
任何东西,把四千行发给浏览器再丢掉 3940 行是那个让屏幕显得坏掉的请求,而调用方
不应该能用一个参数换回它。
### 状态只在变化时广播
`setState` 先比对新旧 state/message,相同就直接返回。阅读页的状态监听会重绘整棵
chrome 树,而一个同步循环本来会通知三次(进门 `syncing`、出门 `idle`、再加一次
多余的 `emit()`),30 秒轮询让这件事在读者什么都不做时也持续发生。
---
## 6. 认证与安全
- **scrypt** 做口令哈希(内置模块,无原生依赖)。比较用 `timingSafeEqual`。
- 登录时即便用户不存在也跑一次哈希比较,避免用时间差枚举用户名。
- 访问令牌是 HS256 签名的无状态令牌;刷新令牌是不透明随机串存库,可按设备撤销。
- 改口令 / 停用账号会撤销该用户**所有**会话。
- 每个请求都从库里重读用户,不信任令牌里的 role——停用账号立即失效。
- 刷新令牌**轮转**:用过即废,降低泄露窗口。
### 不要重新引入原生依赖
镜像构建的稳定性直接决定部署成功率,而部署门槛是最大的获客阻力。
`better-sqlite3`、`argon2`、`bcrypt` 都需要构建时的 `make`/`gcc`,
在部分 NAS 环境会直接装不上。
`node:sqlite` 和 `node:crypto` 的 `scrypt` 都是内置的,所以当前依赖树里
**没有任何需要编译的包**。
这也是发布镜像能做成多架构的前提:`.github/workflows/release.yml`(只在 `v*` 标签或手动运行时触发,
先跑完 CI)在 amd64 和 arm64 各自的原生 runner 上构建同一个单容器镜像,再合成多架构标签;
`npm ci` 在两个架构上都只是下载,不需要编译工具链。
---
## 7. 向后兼容承诺
自部署用户不会及时升级。所以:
- API 只做加法:加字段、加端点。不改已有字段语义、不删端点。
- 数据库 schema 用 `CREATE TABLE IF NOT EXISTS`,升级时新增列/表,不动旧的。
新列写进 `server/src/db/schema.ts` 的 `MIGRATIONS_SQL`(`ALTER TABLE … ADD COLUMN`,必须带默认值),
启动时逐条执行、忽略「列已存在」——旧版本跑在新库上也不会因为多出来的列出错。
- 错误响应格式全站统一,客户端可以一直用同一套解析逻辑。
---
## 8. 客户端渲染
> 界面层的外观规范(图标、设计令牌、焦点、状态表达、文案)在
> [`ui.md`](ui.md)。§8 这一章讲的是**渲染**:分页、注入、量测、以及哪些界面
> 该用组件描述、哪些必须保持命令式。两者的边界是刻意分开的——排版层对书不动手
> (见 §8 与 §9),界面层的外观则必须统一。
已落地。产品设计里的关键决策:**不要直接套 Readium Mobile**。
Readium 用 Readium CSS 主动接管排版、覆盖出版方样式,这与本产品
「忠实还原精排书设计」的核心差异化直接冲突。
实际方案:
- 原生外壳(Kotlin)只负责:文件与缓存、手势、账号、连通性
- 渲染层是 **WebView + 自研 CSS 干预策略**,保留作者样式、只做最小覆盖
- 参考 readium-css 的变量组织方式,但**不引入它的默认接管行为**
### UI 框架:完整 React 应用
前端由 React 19 负责应用页面和页面生命周期,入口是
`web/src/main.tsx` 的 `createRoot()`。启动阶段先由
`web/src/app/runtime.ts` 组装平台、API、同步、离线、播放器和设置资源,再由
`AppProviders` 把它们注入 React 树:
- `react-router-dom` 的 `createHashRouter` 负责 Hash 路由、嵌套路由、参数、Outlet 和错误边界;
- `@tanstack/react-query` 的 `QueryClientProvider` 负责服务端状态、缓存、加载、重试和失效;
- Zustand stores 负责认证、设置、同步(`shared/stores/`)和播放状态(`features/media/stores/`)等跨页面客户端状态;
- 基础组件是项目自己的:`ui/toolkit.tsx`(`Button`、`IconButton`、`Segmented`、`Callout`、`SwitchRow` 等)、`ui/sheet.tsx`
(**唯一**的弹层类型,设置面板、表单、选择器、确认框都是 `Sheet`/`ConfirmSheet`)、
`ui/action-menu.tsx`(每一个「⋯」菜单)、`ui/dialog.tsx`、`ui/pager.tsx`;全局提示在
`ui/notifications.ts`,单个操作的失败用 `ui/floating-notice.tsx`。图标来自 Lucide React
和项目自己的图标字体(`ui/icon.tsx`)。Mantine 已整体移除,组件只读本项目的设计令牌;
- `RuntimeContext` 暴露 API、同步、播放、设置和平台能力,页面不自行组装这些依赖。
业务按 `features/<domain>` 拆分,每个领域按实际需要拥有 `pages`、`components`、
`queries`、`mutations`、`services` 和 `styles`。影音实现位于
`web/src/features/media`;登录(`auth`)、首页(`home`)、全局搜索(`search`)、书架、
书库、书源、「我的」(`account`)和阅读页面也通过各自 feature 接入同一棵路由树。
旧的 `web/src/media`、旧 Screen 页面、旧手写生产路由
和 `mountUI` 已从生产入口移除。`web/src/app.ts` 与
`app/legacy-compat.ts` 只保留 Android 诊断和旧测试需要的兼容形状,不负责页面调度。
**少数人才打开的屏幕不进首屏。** 阅读器(EPUB/PDF/ZIP 解析、分页、朗读)和管理员页面
(媒体库管理、扫描与刮削、条目元数据/匹配/章节/版本/结构、书源编辑、凭据、扩展页)曾占了
只看书架的人 1.5 MB 首屏加载的一半。它们在 `web/src/app/router/lazy-pages.tsx` 里
各自包成带 `Suspense` 的同名组件,路由表里仍写 `ReaderPage`、`MediaLibrariesPage`……
新的懒加载屏幕加在这里,不在页面里临时 `import()`。入口固定为 `assets/client.js`、
样式只有一份(`cssCodeSplit: false`),懒加载块带内容哈希;Service Worker 的预缓存清单由
`web/tools/pwa/build.ts` 列出全部产物,新块不需要手动登记。
React 组件只声明布局和状态,副作用放在 effect、query 或 service 中。这样登录、
书架、书库、书源、设置和影音页面共享同一套生命周期语义,加载、错误、空状态和
返回行为都能由路由与查询状态复现和测试。
### 路由:URL 就是屏幕
界面的第二个结构性问题不是「怎么画」,而是「在哪」。最初的 shell 用
`showShelf()` / `openBook()` / `showManager()` 加一个 `pendingBook` 字段来换屏,
URL 从头到尾不变。手机上这一点几乎看不见——直到读者按返回键:Android 的返回手势
交给 Activity,Activity 看到 WebView 没有历史,于是直接从书架上退出应用。
浏览器上则更糟,地址栏是自托管书库唯一的分发方式,而它既不能分享也不能收藏:
一本书链接不出去,刷新永远回到书架。
所以路由是**值**,不是调用:`web/src/app/router/routes.tsx` 声明
`RouteObject`,`createHashRouter()` 将 fragment 映射到嵌套 React 页面,导航就是
改 URL、让变化回流。
**为什么用 fragment 而不是 `history.pushState` 的路径。** 同一份产物要被两个宿主加载,
而两个宿主都服务不了任意路径:Android 从 `https://appassets.androidplatform.net/`
加载(那里没有服务端,除文档本身以外的路径全是 404),H5 由 reader 服务端托管,
而它没有「客户端路由回落到 `index.html`」这条规则。路径式路由会在 `vite dev` 里一切正常,
在两个生产宿主里 404。fragment 是 URL 里唯一保证属于客户端的一段,所以它是唯一
三处都成立的方案——代价是一个 `#`。
**返回由 React Router 管理。** 页面使用 `Link`、`useNavigate` 和路由层的父级关系,
不再由每个 Screen 自己维护一份 history。嵌套页面通过显式返回按钮回到父路由,浏览器
的 Back/Forward 和刷新都能重建同一页面状态。
当前路由树的稳定入口包括:
| 路由 | 页面 |
| --- | --- |
| `#/` | 首页 |
| `#/shelf` | 书架 |
| `#/search?q=` | 书籍与影音的全局搜索 |
| `#/library`、`#/library/books`、`#/library/files` | 书库浏览、书籍列表、文件管理(仅管理员) |
| `#/sources` 及 `#/sources/...` | 书源中心、浏览、书源编辑、凭据、扩展和插件管理 |
| `#/me`、`#/me/:panel` | 「我的」及其设置面板(主题、浏览、播放、账号等) |
| `#/settings` | 兼容旧深链接,重定向到书架 |
| `#/book/:bookId` | 阅读器 |
| `#/media/:channel` | 影音频道目录 |
| `#/media/:channel/folders/:libraryId?`、`#/media/audiobook/narrators/...` | 按文件夹浏览、演播者 |
| `#/media/:channel/items/:itemId` | 影音详情及管理子页 |
| `#/media/:channel/player?item=&part=` | 播放器 |
| `#/media/:channel/settings/libraries`、`.../tasks` | 媒体库管理、扫描与刮削(旧的 `#/media/:channel/settings[/:panel]` 重定向到「我的」) |
`/sources/search`、旧的 `/sources/:sourceId/:pageId` 扩展页形状和
`/media`、`/media/music`、`/media/audiobook` 默认入口仍由路由表兼容;
新页面应使用当前命名空间和嵌套路由。未知路径回到 `/media/video`。影音内部按频道、
收藏、历史、队列、搜索、详情和管理功能拆成嵌套路由,分页、筛选和播放器参数放在
路由参数或 query 中。
Hash 路由适配 WebView 和静态服务器:宿主只需要返回同一个 `index.html`,客户端从
`#` 后解析页面,不依赖服务端为每个深层路径配置回退规则。登录是认证边界而不是业务
页面;未认证时由 `AuthBoundary` 显示登录表单,认证完成后 React Router 渲染目标地址。
导航只有一份:`web/src/app/AppNav.tsx`,手机上是底部标签栏,701px 起是侧栏(首页、书架、影音、我的)。
它显示与否也由 URL 决定(`navMode()`):阅读器和播放器是 `hidden`,整屏归内容;
顶层目的地是 `top`;从顶层进入的下一级页面是 `push`,手机上用自己的返回箭头替换标签栏,
桌面侧栏保留。
### 阅读器与长生命周期资源
阅读器是唯一的特殊生命周期边界:`ReaderPage` 在 effect 中取得书籍后创建
`ReaderScreen`,把它的 `screen.element` 放入 route host,并通过
`runtime.registerReader()` 注册进度刷新回调。页面离开时先注销回调,再调用
`screen.dispose()` 并清空 host。ReaderScreen 内部的章节加载、分页量测、手势、
朗读和 Shadow DOM 仍是命令式 DOM;其 chrome 使用 React,`StageHost` 只负责把
ReaderScreen 持有的舞台节点放到 chrome 树中的正确位置。React 不介入书籍正文的布局量测。
播放器是应用级资源。AppProviders 把 `runtime.player.element` 挂到
`document.body`,启动 `PlaybackService` 对 `MediaPlayer` 的监听。路由切换只卸载
控制页面,不销毁播放会话;播放器发出 `open-controls` 时由 `AuthenticatedShell`
导航到带 `item` 和 `part` 查询参数的播放器路由。Provider 卸载时停止监听并移除
播放器节点,应用隐藏或离开时统一调用 runtime flush,重新联网时续期会话。
应用主题由 `AuthenticatedShell` 创建的 `ThemeController`(`web/src/shared/theme/theme.ts`)
统一应用,作用域来自 `runtime.mediaApi.preferenceScope()`。控制器只在 `<html>` 上写
`data-theme`(warm、blue、green)和 `data-mode`(light、dark),颜色由
`web/src/styles/theme-tokens.css` 的 `--color-*` 语义令牌给出;系统深浅色、同页保存和
其他标签页的存储变化都会重新应用。阅读器选择黑白灰时只在自己的容器上写
`data-theme="gray"`,视频和音乐播放页在自己的根节点上使用当前主题的深色取值。
主题偏好只经 `readThemePreference` / `saveThemePreference` / `resolveAppTheme`
(React 里用 `shared/theme/use-theme.ts` 的 `useThemeState`)读写,不直接碰存储。
令牌取值的出处是 `docs/prototypes/Reader V3 主题语义令牌.md`:`theme.ts` 里的调色板和样式表
是同一组值,`web/test/theme-tokens.test.ts` 保持两者一致并检查规范里的对比度配对。
字号和圆角同样是按角色命名的令牌(`--text-display` … `--text-micro` 七级、
`--radius-xs` … `--radius-pill` 六级,定义在 `styles/reader.css` 的 `:root`),
新的颜色、间距、时长也写成令牌而不是字面量,细则见 `ui.md` §2。
### 只有一份渲染层
Android 不重写渲染,它加载的是同一份 `web/` 产物。这不是为了省事,
而是因为排版策略一旦有第二份实现就一定会分叉,而排版正是差异点所在。
两边共用的抽象在 `web/src/core/platform.ts`:传输、键值存储、二进制存储、连通性。
Android 只覆盖它真正能做得更好的部分——连通性和设备名——其余全部复用。
### 有一条退出通道:整页图与文档可以由原生画
「只有一份渲染层」是默认,不是教条。有一类内容在 WebView 里是不划算的:
**整页就是一整张图**。渲染一页漫画,WebView 要付:字节跨进渲染进程、在渲染进程里
解码成位图、跑一次样式与布局、再合成一次。对一章文字这些都是必要的;对
「把这张图画满屏幕」则全是白付的,在手机上就是一次翻页卡顿。
所以有一条明确划出的通道:固定版式的整页图由原生画。
规则写死在契约里,也是它能成立的原因:
- **只画整页图。** 漫画页、单张扫描图。任何带文字排版的内容留在 WebView,
因为忠于精排是差异点,而它只有一份实现。
- **全有或全无。** 要么解出位图并显示,要么返回 `false` 让 WebView 照常渲染。
没有中间态,也不会出现「该有图的地方是空白」。宿主拒绝、格式不支持、
payload 坏了、桥接抛异常,四种情况都落到同一条回退路径上。
- **不在 UI 线程解码,位图有上限。** 先读文件头拿真实尺寸,再按视口下采样分配。
一张 4000 万像素的扫描图如果先分配再缩放,就是把目标机型 OOM 掉。
契约横跨三种语言,所以谁也无法用类型系统保证它们一致,`android/scripts/check-shell.sh`
里加了静态校验:渲染模式的词表(`RenderMode`)必须覆盖壳里分支的那几个值,
原生侧的可解码媒体类型必须包含客户端会产出的那些。词表写错一边,
结果不是编译失败,而是**所有页都静默走慢路径**——所以这条检查不是锦上添花。
壳版本 2 引入这套接口。客户端把「壳版本 < 2」当作「没有原生渲染器」处理,
照常在 WebView 里画一切:为了一点性能就拒绝在旧 APK 上运行是错的交换。
### Android 用 https 资产源,不用 file://
把打包好的 Web 应用塞进 assets,最直觉的加载方式是 `file:///android_asset/...`。
这条路对**这个**产品是走不通的:从 `file://` 源发出的 `fetch()` 是不透明的跨源请求,
所有访问服务端的调用都会被拦,而通常的绕法是把 web security 关掉——
那就等于把 WebView 变成一扇敞开的门。
用 `WebViewAssetLoader` 从 `https://appassets.androidplatform.net/` 提供同样的文件,
拿到的是一个正常的安全源:请求按普通 CORS 走,正好和服务端的 CORS 策略对上;
`isSecureContext` 为真,IndexedDB 等 API 与浏览器里行为一致。
这一个选择就是「一份 bundle 两个宿主」能成立的前提。
### 精排 CSS:干预策略
按重要程度排序的三条原则,写在 `web/src/styles/reader.css` 里:
1. **不设任何属性,除非不设会坏。**
2. **每个可调项都是默认 `inherit` 的可继承自定义属性**——「关」的意思是「不动它」,
不是「用我们的默认值」。这一点很关键:给行距写死一个具体值,会把一本按章节
变行距的书压平。
3. **任何改变作者意图的行为都是用户主动开启,且持久化**,绝不是默认。
实际 override 只有七处,每处都在 CSS 注释里写了为什么:图片/表格/pre 在窄屏会撑破
布局、ruby 振假名在 16px 下按默认 50% 太小、长中文标题不换行会溢出。除此之外不动。
对比一下被删掉的做法:reset 样式表、统一的「阅读主题」、给 `p` 加边距。
它们都能让排版「看起来一致」,而一致性恰恰是这里要避免的东西。
### 书的样式隔离在 shadow root 里
书里一条 `p { color: red }` 不能改到书架,App 的 `.topbar` 也不能被书撞上。
iframe 同样能隔离,但每次换章要重建文档:滚动位置丢失、Android 上内存翻几倍。
shadow root 拿到完整样式隔离且没有文档边界,App 还能进去做分页和取选中。
关键是**不往 shadow root 里注入任何 reset**。
但隔离是双向的:**文档里的选择器进不去 shadow root**。这不是风格问题,
它决定了两条规则各自该写在哪儿,搞错了不会报错、只会静默失效:
- `.book-flow`(正文列:版心宽度、页边距、字号缩放、分页列)、`.fixed-page`、
朗读高亮、翻页动画——这些元素在 shadow root **里**,所以规则必须注入到
shadow root **里**(见 `ui/shadow.ts` 的 `FLOW_STYLESHEET`)。
写在 `styles/reader.css` 里等于删除:正文列会以浏览器默认样式渲染,
没有版心、没有边距、翻页模式没有列。而外围 chrome 在文档里、样式完全正常,
所以看上去不像样式表丢了,只像「阅读器没有页边距这个功能」。
- `.book-host`、亮度过场(`::after` 伪元素)、`book-content` 的 `display`
——这些是 shadow **host** 本身,属于文档树,所以规则写在 `styles/reader.css`。
- host 上的状态属性(`data-paginated`、`data-animating`)只能用 `:host(...)`
选择:从 shadow root 内部无法用类选择器匹配 host。
写成 `.book-host[data-paginated] .book-flow` 永远不会匹配,
于是翻页模式静静地不产生任何列,翻页也就无处可翻。
两条规则里的**值**不重复:全部是自定义属性,而属性本身定义在 `:root` 上,
shadow root 会继承。
`touch-action` 是同一条边界上的又一个例子。不声明它,浏览器会认领每一次横向拖动
(触发 `pointercancel`):手机上滑动从来翻不了页,向右滑还会被当成浏览器的返回手势、
直接离开这本书。翻页和固定版式把所有移动交给手势层(`touch-action: none`),滚动模式只留
纵向平移(`pan-y`)。它必须**同时**写在 host(`styles/reader.css`)和 shadow root 里的分页列
(`ui/shadow.ts`)上:分页列才是滚动容器,手指落在文字上时由它往上的那些盒子决定归谁。
### 翻页模式只落在列边界上
翻页模式下,一个不在列边界上的 `scrollLeft` 就是「左半屏是这一页、右半屏是下一页」。
手机每次地址栏或键盘出没都会改视口高度,高度变了列数就变,于是恢复位置时的
`max * fraction` 几乎从来不是边界。所以 `ReaderView` 里所有翻页模式的 `scrollLeft` 写入
(恢复位置、朗读跟随、定位块、搜索)都经过同一个 `scrollToColumnAt()`;浏览器自己发起的滚动
(聚焦、选区自动滚动)由 `snapStrayScroll()` 拉回边界,只有正在跟手的拖动例外。
用户高亮同理不是「量一次就完」:字号、页边距、模式切换、窗口尺寸或一张晚到的图片都会
挪动它下面的文字,所以每次重排之后重画。高亮的章节 id 以**已加载文档自己的** section id
为准(`ReaderScreen.localSectionId`),不按格式猜 `xhtml:` 前缀——猜错的高亮能保存、能同步,
却永远画不出来。
### 窗口化的三个接缝
「一份产物两个宿主」和「一千二百章的书不能先下载整本」是同一件事的两面,
而窗口是这件事的传输边界。跨过它有三个必须写死的地方:
- **换窗口和落到章节是一次操作。** 只换窗口会留下*上一章*在屏幕上:
宿主内容没动、`currentSectionIndex` 还指着旧窗口的下标、页脚还在报读者刚离开的章节。
这正是「点击章节没有反应」的症状。
- **窗口接口只有一个名字。** 客户端建的 `StagedDoc` 叫 `setWindow`,
`StagedBook` 接口叫 `loadWindow`,屏幕曾经用鸭子类型去猜——猜错了,
于是每次跨窗口跳转都是静默的空操作。现在由 `adoptWindow()` 一处识别。
- **窗口由 manifest 的 `groups` 算出来,不是由「40 章」算出来。**
漫画按*卷*开窗,而卷不是 40 页。`spine / 40` 会请求一个不存在的组,
跳转就落到空处。
另外,位置必须按**整书坐标**判断,不能按窗口内的下标:
在窗口最后一章时下标是 39,拿它和窗口长度 40 比会得出「已经是最后一章」,
而后面还有八十章。窗口长度是传输细节,不该决定书在哪里结束。
### 书不能联网
加载时把章节里所有本地引用改写成内部 `reader-res:` 协议,渲染层从内存资源表解出;
绝对 URL 原样保留,然后被 CSP 挡掉。这样一本来路不明的书无法把阅读行为报到远端。
资源按需解出而不是预展开——不然打开一本带大图的 EPUB 会先把整个包塞进内存。
### 阅读位置格式
`r1:<偏移>:<章节 id>`。三点都是踩过之后定的:
- **版本前缀**:服务端把 locator 当不透明字符串长期保存,客户端将来改格式时必须
能认出「这不是我认识的格式」。`r1` 给了这个能力。
- **章节 id 放最后**:章节 id 是文件路径,会含冒号。只从前两个冒号切、剩下全保留,
是唯一不会丢数据的解析方式。
- **偏移是章节内的比例**,不是像素、不是 CSS 列号。列号会随字号变化,
于是读者一调字号,位置就跳到另一个句子上——阅读 App 能犯的最让人恼火的错误之一。
### 待发送队列,而不是「每次都全发」
离线队列记的是**脏集合**:哪些记录还没送到服务端,而不是「当前全部状态」。
两者在一种情况下结果不同,而这种情况一定会发生:记录在请求发出之后、响应到达之前
被修改了(读者在同步进行时翻了一页)。脏集合下,这次更新仍然是脏的,要等**它自己**
的确认才清除;「全发」模式下,它会被那个更早的响应顺手清掉,然后永远送不到服务端——
一个丢失的阅读位置,是这个产品最不能有的失败。
### 离线队列的抗崩
队列在本地镜像里,不在内存里。所以读到一半直接杀进程,位置不会丢——
下一次启动接着送。笔记删除同样写墓碑后再入队,避免离线设备让已删笔记复活。
### TXT 的编码与章节
本地书库里 TXT 占比很大,而它既没有结构也没有编码声明。
编码:先严格校验 UTF-8(自己实现,不用 `TextDecoder` 的宽松模式——宽松模式永远
「成功」,于是你永远发现不了自己解错了)。校验失败**不是**按顺序试
GB18030 再试 Big5,而是两种都解一遍再挑更干净的那个。原因值得写下来:
这两个编码的字节范围重叠得很厉害,同一串字节通常**两边都能解出合法字符**,
所以「先试 A 再试 B」永远不会走到 B,繁体 Big5 的书会以简体码表解出一整本乱码。
真正的证据在各自码表的空洞里——解出来变成私用区字符、替换字符、或者一片汉字里
冒出的假名。按这个打分选优,才能既救回繁体,也不牺牲占多数的简体。
结果里带 `guessed` 标记,界面上告诉用户选了哪个并允许手动覆盖。
章节:TXT 没有目录,只能从标题推断。策略是**宁可少认,不要错认**——
一个 800 字的段落以「第三章」开头时不能当标题,而正文里提到「他在第二章读过」
更不能当标题。两条都有测试固定住。推断不出章节时按长度分段:
一本五千章的网文如果变成一整节,分页会直接卡死。
网页残留:从网站另存的小说常把 `<div>`、`<br/>`、`&nbsp;` 留在 TXT 里。转义后这些标签会被
当成文字画出来,整章还会连成一段。所以在推断段落和章节标题**之前**先过
`cleanTextMarkup()`(`web/src/formats/segments.ts`):块级标签变换行、行内标签去掉、常见实体解码、
只有空白的行算空行。只认真实的标签名——正文里的 `a<b`、`<书名>` 不动,仍按文字转义。
书源(`format: 'chapters'`)的 `text/html` 章节是抓下来的网页片段,同样没有作者排版。
**清洗由插件按书源声明的类型决定**(文本源整理成段落,图片/漫画源原样保留);
阅读器只负责排版:只含文字和换行的章节(`isTextOnlyMarkup`)在读取时用 `htmlToText()`
读回成字符、走上面同一套 TXT 分段和缩进,含图片、表格、媒体的章节保留标记,EPUB 不进这条路径。
放在**读取时**而不是写入时,是为了让服务端早先缓存的章节也被修好。细则见 `ui.md` 的 TXT 排版一节。
### 漫画的页序
按自然序排(`1, 2, 10` 而不是 `1, 10, 2`)。排错不是观感问题,是故事被静默打乱。
文件名无信息量时用页序当标题,因为 `a1b2c3.png` 对读者没有意义。
### 目录书:什么算一页,谁说了算
一个目录变成一本书之后,「这本书有哪些文件」不能由 API 层临时拼:manifest 告诉客户端
哪些路径构成这本书,`/books/:id/file` 就服务哪些路径。两边必须是**同一份清单**。
这一条是被一个真实缺陷定出来的。此前 API 层自己拼路径,用的是 `items[].title`——
对散图恰好等于文件名,对**压缩包里的页**则是包内条目名(`001.png`),那不是一个库内路径。
于是这样一个卷的每一页都 404,漫画打开就是空白,而且服务端日志里只有 404,看不出原因。
现在清单由页面遍历给出,并且**一个压缩包算一页**:它的路径是 `第01卷.cbz`,
是客户端真能拿来发请求的东西;包内页码由 `/assets?ref=page:卷:页` 寻址,不是路径,
也就不该出现在 `files` 里。清单是契约,不是清单式的描述——多一条会被拒,少一条会 404。
顺带一个同类缺陷:`第01卷.cbz` 与 `第02卷/` 并列时,整卷系列在书架上会**整体消失**。
`.cbz` 本身注册为一种书,于是这个目录被判成「书架」而不再交给漫画目录处理器;
卷子目录里只有图片、不构成书,所以也没有留下任何条目。两边都不认,书就不见了。
判据因此改成按**上下文**问:一个 zip 在书架语境里是书,在漫画目录候选里是页材料。
同一个布局还有两处更细的坑,都会让整卷系列消失或读错页,值得一并记下:
- **「卷」的判据不能在最后加一句 `images === 0`。** 整卷系列里放一张封面扫描是常态,
而 `images === 0` 会把这种目录判成「不是漫画」——它就是上面那个消失缺陷的另一条路径。
判据改成逐个来源问:卷压缩包的页就在包里,**一个压缩包本身就是一卷**;卷子目录要往下看
才知道有没有页。一张散图既不能否定同级存在的整卷,也不该在只有它自己的时候被当成书
(那条由 `MIN_IMAGES` 管)。
- **页的引用是 `page:<卷>:<页>`,不是 `page:<下标>`。** manifest 与 `/file` 都从同一次
页面遍历取引用(`files[].ref`),而不是让 API 层从路径或下标反推。「一个压缩包算一页」
意味着它在列表里的下标和它在卷里的位置无关:按 `page:1` 去问一卷,格式处理器解不出这个
引用,异常回退到「当普通文件发」的分支,于是客户端请求一页、拿到的是那个压缩包——
状态码还是 200,只有翻页时才会发现内容不对。
### 一页一个 section
漫画和 PDF 是 `layout: 'fixed'`,每页一个 section,由分页器整屏推进。
固定布局的宿主不做滚动容器——在横向分页器里嵌一个滚动区会产生
Android 上经典的「页面自己往边上飘」bug。
---
## 9. 界面设置与朗读
### 为什么设置不跟着账号同步
同一份「设置」里其实住着两类东西:
| 类别 | 例子 | 真相源 |
| --- | --- | --- |
| 屏幕与环境的属性 | 字号、主题、亮度、页边距 | 本机 |
| 读者的属性 | 阅读位置、笔记、高亮 | 账号 |
把第一类推到服务端,结果就是把手机上的字号搬到平板上打开——这是所有人都讨厌的行为。
所以 `SettingsStore` 存在设备本地(`reader.settings.v1`),同步层完全不知道它存在。
### 新增的每一个排版开关,默认值都是「不动」
这条不是风格问题,是 §8 那个承诺的直接推论。「忠于精排」在实现上只有一种写法:
**除非不设就会坏,否则不设**。
所以行距的默认值是字符串 `inherit`,字体和文本对齐同样;它们经 CSS 自定义属性
继承进 shadow root,书里写死的值自然胜出。字号改的是容器的基准字号,不是每段的大小,
所以 `font-size: 14px` 的段落保持原样,`1.2em` 的段落跟着滑块走。
页边距是唯一一个「用户值必然生效」的排版项。它不是给书的,是给**行宽**的:
一行 40 个汉字在手机上没法读,而约束行宽不能靠改 `p` 的 margin——那正是要避免的编辑。
改成给容器加内边距,作者的样式一条都不用动。
亮度做成了 `mix-blend-mode: multiply` 的覆盖层而不是调色板:换主题应该保留
纸与字的对比关系,直接改颜色会把文字一起压暗,那正是「亮度」滑杆最常见的失败。
### 三个朗读引擎,一个队列
朗读有三条来源,而客户端的其余部分不需要知道正在用哪一条:
| 引擎 | 声音来自 | 什么时候用 |
| --- | --- | --- |
| `native` | Android 外壳的 `TextToSpeech` | 有 `speech` 接口的外壳(版本 ≥ 3) |
| `system` | WebView / 浏览器的 `speechSynthesis` | 默认。免费、离线、无往返 |
| `http` | 服务端配置的 HTTP 朗读服务(系统设置 → 服务配置;`TTS_URL` 环境变量只作初始值),经 `/api/v1/tts` 代理 | 前两者都不可用时 |
`auto` 的优先级是 native → system → http,理由不是「谁更好」而是**谁知道得更多**:
- native 排在 system 前面,尽管 WebView 自己也有合成器。因为那是**两个不同的引擎**:
手机系统装好的中文语音经常不在 WebView 的 `getVoices()` 列表里,
于是读者听到英文或听不到声音,而 Web 层拿到的列表本身就是缺的,修不了。
这是「朗读没有中文声音」最常见的原因,唯一的解法是直接问平台。
- http 排最后,因为它的每一句都要一次请求,而且它需要一个配了 HTTP 朗读服务的服务端。
三者的**暂停语义真的不同**,所以它们不是同一个基类:
- `speechSynthesis` 能暂停,`TextToSpeech` 不能(只能 stop),`<audio>` 能暂停但要重播整句。
- 改语速:`<audio>` 直接改 `playbackRate`(不用重读),另两个必须重读当前句。
- 音调:`<audio>` 根本没有,所以设置面板在 HTTP 引擎下**不显示这一行**,
而不是显示一个按了没反应的滑块。
派生的差异一并被显式处理:HTTP 引擎的单句上限是 800 字,而一段没有标点的中文段落
可能有两千字——切分发生在 `rememberSpeechChunks()` 里,只对引擎生效,
高亮仍然锚定在原来的整句上(碎片带着原句的 `start` 偏移)。
### Android 的原生朗读接口为什么被拆成第二个对象
`addJavascriptInterface` 只能注册**对象**,不能注册某个对象上的属性,
所以 `window.ReaderAndroid.speech` 实际上是 `window.ReaderAndroidSpeech` 这个独立注册的接口,
由 `promoteSpeechInterface()` 在 Web 层重新挂回第一个对象上。
契约(`android-bridge.d.ts`)对外只有一个嵌套对象,接线方式不出现在这一层之上。
和原生整页渲染一样,它按**外壳版本**判断而不是按方法存在与否判断:
版本低于 3 的外壳就是没有这个接口,客户端退回 `speechSynthesis`,
而不是到播放时才发现方法不存在。
### 朗读为什么按句排队
`speechSynthesis` 本身只有一行 API,难的是决定**下一句说什么**。按句而不是按段:
- 高亮能跟着声音走。`boundary` 事件(词级位置)在 Android WebView 上不存在,
在桌面 Chrome 上也不可靠,所以依赖它做高亮等于在目标平台上不做高亮。
- 一章拆成几十句,语速、音调、语音的修改只需重读**当前句**;按段修改则要重读整段。
- 章节切换发生在「一句读完」的回调里,是引擎唯一明确告诉我们「这句话说完了」
的时刻——除此之外的推进都可能把两句同时说出去。
切句规则刻意保守:`.` 后面不是空白就不算句末,`Mr.` / `Dr.` / `3.14` / `e.g.`
全部被这一条挡住。代价是 `end.Next`(句号后不加空格)只剩一句,这比把 `Mr.`
和它的名字切开要小得多。没有标点的长段落按 300 字硬切——Android 的合成器
遇到两千字的单条 utterance 会直接沉默。
### 高亮用 Range 画,不改书的 DOM
把正在朗读的句子包进 `<span>` 是最直觉的做法,也是最糟的:它改写了出版方的标记,
会让作者针对 `p > span` 的样式失效,并且因为行盒度量变化而**重新分页**——
读者一按朗读,页面就跳。所以高亮是 flow 里的一个绝对定位覆盖层,
位置来自 `Range.getBoundingClientRect()`,文字本身一个字节都没动。
代价是高亮必须自己跟随滚动、自己处理换行(一个跨行的句子只有一个矩形),
这比包 `<span>` 麻烦,但换来的是「按朗读不会动版」。
### 朗读队列与章节的边界
引擎只知道一件事:给我一批句子,说我读到哪里了。章节的事由屏幕层负责——
队列耗尽时引擎再问一次,屏幕层决定是「取下一章」还是「到此为止」。
这个分工让「读到章末停下」成为设置项而不是引擎里的分支。
队列的推进由事件驱动,绝不靠计时器:`speechSynthesis` 的 `onend`、`<audio>` 的 `ended`、
原生外壳的 `done`。计时器会漂移,而一次漂移就意味着两句同时被说出来或一句被吞掉。
每个引擎还各有一个看门狗,只在**引擎自己彻底安静了**(长时间熄屏后的
Android WebView 是真实会发生的)才推进,不是为了给朗读计时。
原生引擎的事件带一个**客户端自己生成的 utterance id**,外壳原样回传。
没有它,就分不清「我正在读的这句读完了」和「读者已经跳过的那句读完了」——
后者会让光标多走一格,也就是跳一句。
屏幕层还有一个必须走的公共路径:所有翻页都经过 `turnPage()`。原因是引擎锚定在
**当前章节的 DOM 节点**上,绕过这个方法的翻页会让引擎对着一个已经脱离文档的节点
继续朗读——听起来就像客户端在自言自语。
### 分窗是传输边界,目录是目录
`/manifest` 与 `/items` 都接受 `?group=N`,一次一个窗口(epub 40 章、漫画一卷)。
省略 `group` 等价于 `group=0`——这个默认值是后来改的,值得记一笔:最初省略参数
意味着「发全部条目」,于是一本 1200 章的书在它**最需要快的那个请求**(打开书)里
收到 1200 条记录。分窗如果只对显式传参的客户端生效,等于没做。
目录则相反:`/toc` 必须完整。两者要的性质相反,合成一个接口必然做错一个——
第一版就是这么做的,结果是目录面板显示「第 1 章 – 第 40 章」,服务端的分页边界
漏进了 UI。
### 书自己的目录页也是一个章节,它的链接要当章节切换
一本 EPUB 的 `nav`/`toc` 文档是**普通的 spine 条目**,读者翻到它、点「第三章」,
要的是换章。但服务端把章节里的相对引用都改写成了 assets 端点的绝对 URL
(否则相对 href 从归档外面根本解析不到),于是那个 `<a href>` 默认是一个
**资源请求**:浏览器去下载另一章的文档,读者看到的不是第三章,而是一张坏掉的页面。
客户端因此要在**点击**这一层把它读回来:`chapterRefFromLink()` 认出
`__reader-book-resource__=1&ref=OEBPS/ch3.xhtml` 里的 `ref`,拼成 manifest 的
`xhtml:<path>`——和目录面板给的是同一种引用,所以它走的是**同一条**跳转路径
(`goToChapterRef`),跨窗口取窗也一样。
两条附带的规则同样重要:识别不出来的链接**不放行**(一本书里的站外链接不能让
读者离开阅读器,WebView 壳里更是回不来),而**纯片段链接**(`#note3`)交给浏览器
自己跳——脚注必须能用。边界写在 `ReaderView.bindChapterLinks` 里,因为它是唯一
同时看得见 shadow 树里真实节点和网络地址的地方。
### 章节的读取代价
一次章节读取 = 一次中央目录读(打开时)+ 一次条目的局部头 + 该条目的数据。
这是 `zip-reader.ts` 只解析中央目录、`openStream` 按偏移读单个条目的原因;
整包 `loadAsync` 会让「翻到下一章」变成「重新解压整本书」。
客户端的对等物是 `formats/remote-zip.ts`:同样的中央目录解析,走 HTTP Range。
它存在的意义是让**浏览器端**也能不下整本书——否则「服务端分章」只对原生端成立。
(注意:`check-shell.sh` 里新增的渲染模式词表校验,以及 `colors.xml` 里
`--` 在 XML 注释中非法、Kotlin 侧 `Uint8Array` 不存在这两处,
都是**第一次真正编译 Android 壳**时才暴露的。写不能编译的代码等于没写。)
---
## 10. 书源与插件
书源是「书库之外的书从哪来」:本地书库、OPDS 目录和进程插件(`server/src/sources/`)。
宿主 `SourceHost`(`server/src/services/source-host.ts`)持有注册表、来源实例的生命周期和
凭据边界;路由在 `http/routes/sources.ts`,接口形状见 [`api.md`](api.md)。
- **插件是进程,不是模块。** `ProcessPlugin` 以子进程运行受信任的 Node 插件,按行收发
JSON-RPC(stdio),有超时和单条消息大小上限,返回值经 `protocol.ts` 解码后才进入宿主。
这是**故障隔离而不是沙箱**:插件有服务进程同一个系统用户的权限,所以安装是管理员操作,
安装包必须留在 `DATA_DIR/plugins` 里(`PLUGIN_PATH_ESCAPE`)。一个插件崩了、卡了,
丢的是这一次调用,不是整个服务端。示例在 `examples/plugins/demo-chapters`。
- **凭据加密存库。** 来源凭据用从签名密钥派生的密钥加密,接口只回报「有没有」,不回原文。
- **搜索是一条流,失败按来源报告。** `POST /sources/:id/search` 的响应是
`text/event-stream`(`http/search-stream.ts`):同一个请求里依次推出每一页结果,宿主按
`ref` 去重、按 `resultLimit` 截断、校验游标不重复和批次进度不倒退。一个来源实例可能聚合了
很多书源(例如书源插件),其中几个超时或报错不应让整次搜索失败,所以每一页可以带
`errors[]`(`source` + `code` + `message`)。每一块只带**自上一块以来**的失败,客户端
(`SourceCatalogPage` 的 `mergePages`)必须按 `source:code` 累积,而不是用最新一块替换——
否则读者只看得到最后一个出错的书源。同一用户、同一来源、同一 `sessionId` 的新连接会先中止旧连接,
`…/search/cancel` 显式结束会话。
- **书源的书也是书。** 取得(acquire)一本书源书时,`publications/chapters.ts` 把它写进同一张
`books` 表(`format: 'chapters'`)并缓存章节,书行仍只由 `ShelfService` 组装(§5 那条规则);
章节的清洗与排版边界见 §8「TXT 的编码与章节」。