主题
Lampio 桌面端 · Rust / Tauri 侧指南
会话触达
src-tauri/下任何文件时,本文件叠加到根CLAUDE.md之上。 从根 CLAUDE.md 拆出:2026-08-28(docs/plans/archive/rvh-merge-plan.md阶段 1,裁定表见其 §3 T1-1)。⚠️ 这份是叠加,不是替代。 双端契约仍在根
CLAUDE.md:红线 #5i / #6d / #9 / #10、 §3 Auth & Sync 同步矩阵、2-Button 三端映射、§5 Supabase 共享表、§9 双端整合。 改 sync / schema / 词形归一 / 预装库必须回看根文件——根 §4 有一张 20 行全量红线索引, 标明每条正文在哪,本文件承接其中 15 条。⚠️ 注入挂在文件工具上(Read / Edit / Write),
cat/grep/sed读同一个文件不触发 (2026-08-28 实测,见合仓计划 §2 P0-2)。全程走 Bash 的会话拿不到本文件, 这正是根侧必须保留全量红线索引的原因。
目录
1. 架构要点
Multi-Webview 架构
Window
├── Main Webview (label: "main", auto_resize) ← React 应用
└── Content Webviews (label: "content-{id}") ← 动态子 webview
├── content-0: 网页/EPUB
├── content-1: 网页/EPUB
└── ...- 窗口在
setup()中手动创建,tauri.conf.json中windows: [] - 标签切换通过 offscreen 定位实现(不销毁 webview)
- Content webview 创建时注入
content-script.js(viainclude_str!()) - 不是每个 tab 都有 webview:判据统一在
useTabsStore.needsContentWebview()(禁止在 调用点手拼条件——BrowserPage 有创建/显隐/resize 三处必须完全一致)。两类例外:空白 tab (+新建,还没有 URL)与text-draft(粘贴文本编辑态,正文在 React 的<textarea>里)。 后者尤其要收起来——「重新编辑」是就地切换,那个 tab 的 webview 还停在已发布的文档页上, 不藏就会盖住编辑器(content webview 是 main 的原生兄弟层,永远在上)
DB 双通道
| 通道 | 使用者 | 库 |
|---|---|---|
| tauri-plugin-sql | 前端 JS | SQLite plugin |
| rusqlite 0.31 | Rust commands | 共享 libsqlite3-sys 0.28 |
Content-Script IPC
Content webview 中不能使用 @tauri-apps/api,必须使用:
js
window.__TAURI_INTERNALS__.invoke('command_name', { args })架构设计原则
以下三条原则从 Review 流程重构中提炼,适用于所有涉及 Webview 和跨边界通信的功能开发。详见
docs/plans/archive/webview-coordinator-refactor.md。
原则 1:能用 Tab 就不造特殊实体
需要在 content-area 显示 webview 的功能,必须作为 Tab(useTabsStore)接入。用 tab.type 区分特殊行为,复用 Tab 系统的生命周期管理(创建/销毁/显隐/resize/切换)。禁止在 App.tsx 中为新功能新增 webview 协调 useEffect。
原则 2:状态归属——谁渲染谁拥有
- 只有一个组件读写 →
useState(local state) - 多个组件读 → 最小共享 Zustand store
- 跨边界传入 → Tauri event → 接收方 local state 或 store
- 禁止同一信息在多处维护 + 手动同步
原则 3:跨边界通信只用两种模式
- React/content-script → Rust:
invoke() - Rust → React:
emit event→listen() - 禁止 UI 组件直接调用 Tauri webview 命令(通过 Tab store 方法)
- 禁止 多个组件各自 listen 同一事件各自处理
2. 技术红线(桌面端)🔒
🔒 本节 = 桌面端专属红线的唯一真相源,承接根
CLAUDE.md§4 的 15 条。 编号是全局唯一标识符,不因分表而重排——跨端文档(docs/cross-end/*)按编号引用。 缺号的五条(#5i / #6d / #6e / #9 / #10)是双端契约,正文留在根CLAUDE.md§4; #8(组件内禁 hex 字面量)在../src/CLAUDE.md。 根 §4 的红线归属规则(哪条红线该写在哪、🚫 红线不许只活在docs/plans/)照旧适用。
| # | 规则 | 原因 |
|---|---|---|
| 1 | rusqlite 锁定 v0.31 | 与 sqlx-sqlite 共享 libsqlite3-sys 0.28 |
| 2 | SQLite 查询禁止 LOWER() | 用 COLLATE NOCASE(Unicode 支持) |
| 3 | Content-script 用 __TAURI_INTERNALS__.invoke() | @tauri-apps/api 在 content webview 不可用 |
| 4 | DOM 操作前断开 MutationObserver | 否则触发无限循环(用 withObserverPaused) |
| 5 | Sync pull 用 server_updated_at=gt.{last_sync_at} | 服务端 trigger 管理的列,防止客户端 ts 漂移造成漏数据 |
| 5a | last_sync_at 仅在 errors.is_empty() 时推进 | 否则 transient error 会让 watermark 跨过未拉取的旧数据,造成永久丢失(2026-04-22 修复) |
| 5b | pull_* 的 INSERT 必须写 user_id = config.user_id | clear_learning_data_if_user_changed 的 WHERE 包含 user_id IS NULL,pull 漏写 user_id 会被当作游客数据误杀,紧接着产生 FK violation 级联(2026-04-22 修复) |
| 5c | 登出时必须调 useTabsStore.resetAllTabs() 销毁 content webview + 重置 review 到 empty state | 否则下一用户登录会看到上一用户的浏览/复习页面(隐私泄露 + 状态卫生,2026-04-22 修复) |
| 5d | sync watermark 推进到本次 pull 实际返回行的 MAX(server_updated_at)(空 batch 时保持原值) | server_updated_at 由 Supabase trigger set_server_updated_at 在 BEFORE INSERT/UPDATE 时强制 clock_timestamp(),是行的真实 commit 序。客户端 updated_at 可比服务端 commit 时刻早任意长(离线/异步/批量 push/时钟差),用客户端 ts 作 watermark 会让 gt.客户端ts 永久漏掉 客户端ts < watermark < server_updated_at 的行。fetch_remote 内部已 drain 每张表所有页,全局 MAX 安全。原 sync_start_at - 5s replica buffer 模型废弃。详见 docs/plans/archive/sync-server-timestamp-plan.md(2026-05-22 落地) |
| 6 | Soft-delete 用 deleted_at 列,不 hard DELETE | 所有查询加 WHERE deleted_at IS NULL |
| 6a | 同步表的删除一律软删,且父行删除必须在同一事务里显式软删整棵子树——禁止依赖 FK CASCADE | CASCADE 只在硬删触发,而硬删会物理抹掉子表刚落的 deleted_at 墓碑 → 子树在云端永生、下轮 pull 复活(v23 两张子表必须同时补墓碑列、v30 reading_notes 补齐,同一条理由)。且 rusqlite 通道从未 PRAGMA foreign_keys=ON(db/helpers.rs 只设 WAL + busy_timeout),CASCADE 在 Rust 侧其实一次都没真正触发过——写「靠 CASCADE 带走子表」等于什么都没做,只留悬挂 FK 的孤儿行。模板见 notes/crud.rs::soft_delete_note_tree / remove_reading_page / vocabulary/crud.rs::soft_delete_word_traces。⚠️ 「子树」按父行算,不按命令算:凡把某张父表的行打成墓碑的路径都要带走子行,包括语义上不叫「删除」的那些——known_words::add_known_word(标「已认识」)也软删 learning_entries,2026-08-26 前它 cloze 与 links 两样都不带走,是三条路径里最松的一条。⚠️ join 行(两个父)必须显式裁定随哪个父走并写进注释:word_page_links 随页走(删笔记本 → soft_delete_note_tree)也随词走(删词 / 标已认识 → soft_delete_word_traces)。后者是 2026-08-26 的裁定(K1 面 A):删词的既定语义是重置该词全部学习记录(复活时 SM-2 清零、语境池早就一起清),links 是同一形状的残留;且「留着好让 re-add 恢复来源」不需要活行(save_word 的 ON CONFLICT DO UPDATE SET deleted_at = NULL 本就会复活软删的 link)。回归测试 vocabulary/crud.rs::word_subtree_tests(含「活词的 link 一条不许动」反向断言)。🔴 跨端并发(一端删词、另一端同时存同一个词)仍会漏下孤儿,写侧根治不了——兜底是巡检 tombstone_parent(monitoring.sql §3b + scripts/db-audit.sh §3b),别指望删除路径 |
| 6b | push payload 的 deleted_at 必须传本地真值,禁止恒写 None/null | PostgREST upsert 会把显式 null 写进目标列 = 远程复活对端刚删的行。同理 pull 的 ON CONFLICT DO UPDATE SET 子句不写 deleted_at(墓碑态只由三分支逻辑决定,绝不被远端活行覆盖) |
| 6c | pull 的父行守卫必须显式回答三态:活 / 墓碑 / 缺失——禁止压成两态(走 pull/mod.rs::parent_state,别手搓) | 父表改软删后行仍在,两种压法各有各的事故:① 裸 COUNT(*)/EXISTS 把「墓碑」并进「活」→ 放行远端活的子行,在已删父行下重建整棵子树(页活着、父已死 → UI 里隐身的孤儿,且读侧查询都带 deleted_at IS NULL,两端都看不见);② AND deleted_at IS NULL 把「墓碑」并进「缺失」→ 父行只是还没拉到的子行被 skip,而 watermark 只按取回的行推进(红线 #5d),跳过的行下一轮 server_updated_at > last_sync_at 再也够不着 = 永久丢行。所以 2026-08-26 前那句无条件「必须带 AND deleted_at IS NULL」是错的(它把 ② 写成了规则),K1 面 B 裁定改成本条。落地形状:缺失 → skip(FK 落地限制,word_page_links 两侧 FK 都 NOT NULL)或 照落(父列可空时,如 pull_reading_pages 的 note_id——丢页比孤儿更贵);墓碑 → skip 远端活行,远端墓碑照常落地。另一类是语义绑定(这行会驱动未来写入或展示,如 pull_domain_prefs / get_note_for_domain 的 note 绑定):那里父行必须活着,绑不上就置 NULL 回落,不是同一个判断。回归测试 pull/mod.rs::parent_state_tests |
| 7 | save_word 须重新激活 soft-deleted 条目 | 避免重复记录 |
| 11 | 已应用迁移的 SQL 本体一个字节都不能改。 范围是全部已出厂迁移,不只 schema.sql:assets/sql/ 下三个 include_str! 文件(schema.sql=v1 / init_data.sql=v2 / seed_reference_words.sql=v3)加上 migrations.rs 里 v28 起的内联 SQL 字面量。它们只在「用户本地库仍视为可重置」阶段允许直接编辑;一旦某次发版被定为数据基线(不再重置用户库),已出厂的迁移永久冻结,此后新表 / 新列只进新编号迁移,禁止再向已出厂迁移双写 | sqlx 对每条已应用迁移按 SHA-384 校验(Sha384::digest(sql.as_bytes()),只哈希 SQL 本体,description 不参与),改它 = 改指纹 → 任何「编辑前建库、之后未删库」的存量库启动即 VersionMismatch → 中止整条迁移链(pending 新迁移永不执行)。删库重建之所以一直没暴露,是每次删库都用当时 schema.sql 重存指纹、始终自洽。首个发布版(2026-07-19+)已让 schema.sql 进入野外——从此每改一次都会静默打断未干净重装的存量内测用户;「压平」仅在你仍接受重置这些用户数据时可用。冻结与否的确认动作挂在 /release(见 §8 + 该 skill)。v26 domain_zoom 双写踩坑教训;init_db_state 已加 rusqlite 幂等兜底建表补救。⚠️ 删掉或改号一条已出厂迁移同样致命(sqlx 报 VersionMissing,且发生在任何 pending 应用之前)。守门见 §5;判断层见 docs/verification/data-baseline.md |
/arch-checkskill 自动扫描这些规则 + 文件大小/store 污染/未归档 plans 等软规则。
3. 数据库
⚠️ 本节只讲 RB 本地库(SQLite 迁移链 + 预装资产)。 Supabase 共享表 / 缓冲池角色 / soft-delete 双端语义在根
CLAUDE.md§5—— 那三小节是 RB / RVH / admin 的共享契约,改它们前先回根文件。
迁移版本
🔒 数据基线已冻结(2026-07-26,基线版本 0.1.0-dev.8)。 此前的「开发期压平策略」(直接改 schema.sql baseline + 删库重建)已终止——2026-07-26 做了最后一次压平(旧 v1→v27 全链折叠进 v1 baseline),随首个带 Tauri Updater 的构建发出。自此已出厂的迁移永久 append-only —— 不只 schema.sql(v1),还包括 init_data.sql(v2) / seed_reference_words.sql(v3) 与 v28 起写在 migrations.rs 里的内联 SQL。
assets/sql/下那三个文件被migrations.rs用include_str!原样当作 v1/v2/v3 的 SQL 本体,sqlx 对每条已应用迁移做 SHA-384 checksum 校验(口径 =Sha384::digest(sql.as_bytes()),只哈希 SQL、不含 version/description)。编辑它们中任何一个 = 改对应迁移的指纹;v28 起的内联字面量同理;任何「编辑前建库、之后未删库」的存量库启动时会VersionMismatch→ 中止整条迁移链(pending 的新迁移永不执行)。基线已进野外(updater 会把新构建自动推给存量用户,其本地库持久存在),删库重建不再是选项。 纪律(append-only + 迁移不可变):① 已发布的迁移(含 v1/schema.sql)一个字都不再改;② 所有新表 / 新列只进新编号迁移(v28、v29…),禁止再向 schema.sql / v1 baseline 双写;③ 新迁移用CREATE TABLE IF NOT EXISTS/ADD COLUMN幂等加法式。 守门(三道,覆盖面不同,别只记住第一道): ①scripts/check-schema-frozen.mjs—— 比对assets/sql/下三个文件的 SHA-256 与冻结基线(CIci.ymlfrontend job 跑)。它只守文件,守不到 v28+ 的内联 SQL。 ②migrations.rs::migration_chain_tests::shipped_migrations_are_byte_frozen—— 拿src-tauri/src/db/shipped_migrations.txt(指纹清单,真相源 = 发布 tag)逐条核 sqlx 口径的 SHA-384,内联迁移与文件迁移一视同仁,且会抓到「已出厂迁移被删/改号」(2026-08-27 补,此前 v28+ 无任何守门)。 ③migration_chain_tests::flattened_baseline_matches_terminal—— v1+v2+v3 终态 == 冻结前 goldensrc-tauri/src/db/terminal_schema_baseline.txt。 部署/本机层另有scripts/migration-verify.sh(含「本机存量库的_sqlx_migrations与 HEAD 逐条比」)。 教训来源:v26domain_zoom双写 schema.sql,令 07-19 前建库的存量库断链(init_db_state已加 rusqlite 幂等兜底建表补救,见db/helpers.rs)。
逐条迁移明细在 docs/database-schema.md §10——唯一真相源,本节不再并行维护第二张表。 新迁移编号接着 src-tauri/src/db/migrations.rs 里最大的那个往下排(读它,别读文档—— 两份表并存期间同时漏记了 v33,且 CLAUDE.md 那份把早已完成的 v28 收尾项写作"阻塞中"挂了半个月)。
预装库 reseed 史(v19 近义辨析 / v20 词源 / v20 lemmatizer 折叠 / v21 短语分档 等)归 docs/cross-end/README.md 的编号总表 + CHANGELOG;下次 reseed 怎么做 归 /vocab-reseed skill(含「新增独立列必须进 DO UPDATE SET,否则只有老用户拉不到新数据」那条坑)。
预装数据
- 预装词库 =
assets/lampio_dict.db(双端共用的那一个物理文件 ——rvh/assets/databases/lampio_dict.db是指向它的 symlink;红线 #10;2026-05-14 v10 起替代原方案)。 词条数刻意不在这里写死 —— 真相源是src-tauri/src/db/helpers.rs的VOCABULARY_SEED_VERSION常量(形如rb-<词条数>-v<迁移号>-<主题>-<日期>)。选它当真相源不是因为它更权威, 而是因为它不会静默过期:reseed 必须 bump 这个常量,不 bump 就不会重新灌库 (helpers.rs::seed_vocabulary比对相等即 return),所以「常量没变」= 「库也没变」。 现查两行 SQL:⚠️ 别再往这行填绝对数。 上一次填的是「12,291 条预装词汇」,冻在 2026-05-14 的 v10, 此后多轮 reseed 一次都没回写 —— 而同期sqlite3 src-tauri/assets/lampio_dict.db \ "SELECT COUNT(*) AS 总数, SUM(word NOT LIKE '% %') AS 单词, SUM(word LIKE '% %') AS 短语 FROM vocabulary;"backlog.md的 v28 记录里写着的是另一个数。 每 reseed 一次,写死的数就多一处要人肉同步的副本,而漏同步不会有任何东西变红。 - ~193 条预装 reference_words(国家/城市/组织/缩写/品牌 +
chinese_meaning)。 2026-08-31 起是纯数据,不参与任何查询过滤(原黑名单角色已退役,见 §5vocab_scope.rs那行) - 替代了原 927K Kaikki 词典方案
- Supabase 连接配置(
supabase_url+supabase_anon_key):已改为构建期注入(roadmap 1-5,2026-07-23)——由src-tauri/build.rs从src-tauri/.env.local(gitignored)或 CI 环境变量注入为编译期常量(env!),commands/init.rs::seed_vocabulary迁移后 UPSERT 写入 settings;init_data.sql(v2) 不再 seed 这两 key。缺配置 build.rspanic!fail-fast。anon_key 公开 by design(RLS 保数据),注入目的是防项目身份跟随 fork 的源码树流出,非密钥保密。fork 者按src-tauri/.env.example自建.env.local。CI 需在 GitHub 仓库 Secrets 配RB_SUPABASE_URL/RB_SUPABASE_ANON_KEY(ci.yml rust job + release.yml build job 已接线)。旧字面量仍在 git 历史 + 已发版二进制——真正作废需轮换 Supabase anon key(跨端协调,见 backlog)。
4. Rust Commands 模块
清单以 src-tauri/src/commands/mod.rs 的 pub mod 声明为准(ls 或读 mod.rs 都比这里新鲜)。
⚠️ 七个模块已目录化,引用时写目录路径:
sync/{common,pull,push}——其中pull自己又是一层目录:sync/pull/{vocab,library,prefs}(2026-08-27 拆,原单文件 1804 行)·vocabulary/{crud,discovery,memory,query,sm2}·epub/{nav,position,search}·text_source/{clipboard}·reading/{analysis,view,stats,progress,recommend}·notes/{source,crud,page,content,continue_reading}(reading / notes 是 2026-08-25 拆的,原单文件各 2144 / 2126 行)。 写commands/sync.rs/commands/reading.rs/commands/notes.rs/commands/sync/pull.rs会指向一个不存在的文件——这几个恰好是最常被引用的,值得单独记一笔。 各自的职责边界见对应mod.rs的模块注释(reading 那份写了为什么是 5 块而不是原计划的 3 块;sync/pull/mod.rs那份写了为什么它做 re-export 而 reading / notes 刻意不做)。 ⚠️ 再拆下一个之前先看docs/coding-standards.md§10.3 末尾 那条坑:git add -A <新目录>不会暂存同名旧文件的删除,提交出来的 commit 单独 checkout 编译不过, 而本地全绿、毫无提示。拆sync/pull时按那条走了git rm <旧文件>+ 独立 worktreecargo check复验。
5. 非显然约定索引(Rust / content-script 侧)
这里只放"读代码得不到"的东西。 加新行前先自问:打开那个文件的前 20 行能不能得到这句话?能,就别加。 文件有什么,
ls和 Grep 比这张表新鲜;踩过什么坑,只有这里有。 (本节曾是一张 44 行的文件清单,34 行只是把文件名换成中文再说一遍——那种行是会过期的副本, 2026-08 抓到过两行指着已删除的组件,故砍到只剩坑。) 前端侧的同款表在../src/CLAUDE.md。
| 文件 | 非显然之处 |
|---|---|
src-tauri/src/content-script.js | 是 esbuild 产物,改它无效。源在 src-tauri/src/content-script/,改完要 pnpm run build:cs(或 watch:cs)重新产出 —— 直接编辑产物会在下次构建时被静默覆盖(2026-08-30 自根 CLAUDE.md §2 下沉) |
src-tauri/src/commands/snapshot.rs | 快照文件「在哪、缺了怎么办」的唯一权威处:library 路径解析(按用户隔离)+ 云端按需取回 + 存量目录迁移。磁盘按用户隔离 library/{user_id}/...,但 cached_file_path 与 rb-cache:// URL 不含 user_id(只有 Storage 前缀才拼) |
src-tauri/src/cache_protocol.rs | rb-cache:// 处理器的三态回落:命中直接喂文件 → 缺失则走 snapshot 取回 → 取不回渲染降级页而非空白页。少任何一态都会表现为"换设备后正文永久丢失" |
src-tauri/src/db/vocab_scope.rs | 「这个词算不算可学 / 可计难度」的唯一判据,7 条查询共用(发现候选池 / 快速分级 ×2 / 校准探针 / 页面难度 / RSS 难度 / 推荐覆盖率)。禁止在调用点手搓等价 SQL —— 分叉不会报错,只会让某一处对同一个词给出不同答案;两条结构守卫测试钉住这点。⚠️ lookup_word / browse_vocabulary 绝不调它:专名该查得到,只是不该进学习循环(三个角色:可查 / 可学 / 计难度,判据必须分开表达)。性能靠 v34 部分索引 idx_vocab_tagged,去掉它只是慢、不会错 |
| 🔒 专名过滤只有这一处判据 —— 别再引入静态整词黑名单 | reference_words 那张 193 词表 2026-08-31 已退役过滤角色(表和数据留着,是 L-D 专名中文兜底的数据源)。它是个极易被顺手接回的东西 —— 只是一句 NOT IN。实测代价是反的:193 行里 145 行不在 vocabulary(空转)、20 行已被上面的谓词覆盖(冗余),独有的 28 行里 17 行是误杀(fox/dna 这类权威分级词 + 15 个带 adj 的国籍词,而同类的 american/chinese/french 因为不在 2026-04 那份 seed 里反而一直正常 —— 同一词类被劈成两半,分界线只是 seed 抄了哪些地缘新闻国家)。症状是静默的:生词本里在、复习卡里出现、统计里算数,唯独页面上不高亮。⚠️ 它有两条腿:Rust 的 NOT IN + content-script 的 Set.delete,只拆一条等于没拆。守卫 vocab_scope::the_reference_words_blacklist_stays_retired(覆盖 Rust + JS 七个文件,带阳性对照)。某个专名漏网的对症修法是 L-C 的预装库补标,不是加名单 |
src-tauri/src/content-script/features/toolbar.js · popup.js | 选区工具栏与查词弹窗在 content webview 里,不在 React 侧。旧的 SelectionToolbar.tsx / WordPopup.tsx 已删——别再去 src/components/ 找 |