Skip to content

Architecture Overview

51hhh edited this page Sep 16, 2026 · 1 revision

架构总览

技术边界

层 技术 拥有什么
主窗口 vanilla HTML/CSS/ES modules 高频剪贴板列表交互
功能岛 React + TypeScript 贴图窗口、截图覆盖层(含标注)、图片查看器、长截图控制窗
系统层 Rust / Tauri v2 剪贴板、数据库、窗口、截图帧、Portal 会话、贴图数据、密钥、网络请求
IPC src/js/api.ts 唯一 Tauri 调用边界,ipc-types.ts 对齐 Rust serde 字段

主界面刻意不引入框架:剪贴板列表是最高频的交互路径,保持它是稳定的 DOM 操作比统一技术栈更重要。需要复杂状态的场景(标注、画布、查看器)才做成隔离的 React 功能岛,功能岛不反向重写主界面。

主窗口目前是混合形态:js/app.js 作为入口,通过 react/main/mount 挂载剪贴板工作区与翻译面板两个 React 组件。

后端模块地图

src-tauri/src/
├── lib.rs / main.rs            Tauri builder、managed state、快捷键后端选择
├── app/                        自启防护、托盘、快捷键动作、窗口事件
├── commands.rs / commands/      按领域拆分的薄 IPC 命令层
├── platform/                   操作系统/会话/桌面环境/能力状态的单一事实源
├── clipboard_watcher.rs + /    轮询、去重、内容分类、写入、唤醒、tmux
├── storage.rs + storage/       SQLite/FTS5、清理、统计、URL 缓存、翻译记录
├── paste/                      自动粘贴协调器、Portal/X11 后端、token 持久化
├── capture/                    CaptureSession、冻结帧、覆盖层、长截图、裁剪
├── screenshot.rs + screenshot/ 帧契约、PNG 编解码、平台回退链
├── pin/                        PinManager、窗口、工程容器、权威合成器、重采样
├── viewer/                     独立图片查看器窗口与不可变快照
├── translation/                provider、超时重试、request-id、密钥、TTS
├── ocr.rs + ocr/               Tesseract 子进程与结构化增强引擎
├── code_detection.rs           本地二维码 / Code 39 识别
├── window_controller.rs        窗口几何约束
├── dbus.rs                     全部阻塞式 D-Bus 调用的唯一入口
├── i18n.rs                     Rust 侧静态文案
└── error.rs                    领域错误聚合与稳定 code()

约 5.9 万行 Rust。前端 7 个入口:index.html、settings.html、pin.html、capture-overlay.html、viewer.html、longshot-controller.html 及共享模块。

三个关键的所有权约定

剪贴板入库只有一条路径

clipboard_watcher 是唯一写库的地方。writer.rs 的写入口只负责写系统剪贴板,写完敲 wake::nudge() 唤醒轮询。不要让写入方自己 insert_clip —— watcher 哈希的是它自己从剪贴板 RGBA 重新编码的 PNG,字节对不上就会在下一轮再插一条重复记录。详见剪贴板管线。

平台能力只有一份判定

platform/ 是操作系统、会话类型、桌面环境和能力状态的单一事实源。get_platform_info 与截图诊断复用同一份结构;前端只消费 typed PlatformInfo,不读 user-agent、不自己推断平台。

能力探测要区分三种状态,混为一谈会产生错误的用户提示:

  • 服务不存在
  • 服务存在但具体接口缺失(按接口读 version 属性判定)
  • 接口存在但需要用户授权

最终图像产物只有一个合成器

pin/render_v2.rs 是贴图工程与截图选区的权威合成器。Copy、扁平保存、可编辑保存、截图的 Copy/Save/Pin 全部复用同一语义,走锁定版本的纯 Rust CPU 路径和内嵌字体,不查询 WebView、GPU 或系统字体。前端只提交操作文档(renderer v2),不上传一份互相竞争的 WebView PNG。

这样做的直接收益:相同输入可以用 RGBA SHA-256 金图锁定,预览与导出不会不一致。

阻塞式 D-Bus 的单一入口

dbus.rs 存在的原因是一个具体的运行时冲突:依赖链打开了 zbus 的 tokio feature,于是 zbus::blocking 内部用静态多线程 runtime 做 block_on,在 tokio async worker 线程上调用必然 panic(Cannot start a runtime from within a runtime)。

dbus.rs 先跳到一条干净的 OS 线程再建连接,调用方不必关心自己跑在什么线程上。其它地方不要直接用 zbus::blocking。

session 连接是复用的:Connection::session() 每次都要重做 SASL 握手 + Hello(1~2 ms),而贴图缩放每一帧都要发一次窗口摆放请求。连接缓存在 OnceLock<Mutex<Option<Connection>>> 里,失效判据是:

  • Error::MethodError 说明对端应答了 → 连接是好的,绝不能重连重试
  • 其余错误 → 丢缓存重连一次

存入缓存的判据必须是同一个的反面。只按「调用成功」判断会把一条被业务错误拒绝、但本身完好的连接扔掉,于是每次失败的探测都要重新握手。

内容类型只有一套标准

类型判定的唯一入口是 js/preview/classify.js 的有序规则表(guard 廉价预筛 → detect 承担语义 → 渲染器),先匹配先赢,结果只显示在预览面板的类型 badge 上。

列表行故意不显示类型。 后端 content_type 只有 text/html/image 三档(按剪贴板 flavor 定),预览是按内容嗅探(YAML/JWT/时间戳…),两边同时显示必然自相矛盾——同一条 HTML 片段会一边被标成 HTML、一边被标成 YAML。因此列表行的 meta 只保留「大小 · 时间」。

顺序本身就是语义:JWT 必须早于可逆编码,否则三段 Base64 会被当成普通 Base64。

一个值得记住的判据设计

哈希与可逆编码的边界靠「解码结果是否是合法 UTF-8」划分,不靠顺序也不靠长度。

原因:纯 hex 摘要同时也是合法 Base64 字符集,而 atob/hex 解出来的是「一字节一字符」的 Latin-1 串。按 Latin-1 判可读性时 0xA0–0xFF 全算正常字符,随机字节里通常有七八成落在这个区间,比例阈值拦不住,于是 MD5 被标成 BASE64 并显示成乱码。反过来把 hash 提到 encoding 前面、或在 hex 分支里按长度黑名单排除 32/40/64/128,又会把正好 16/20/32/64 字节的 hex 编码文本误判成摘要。

真正编码过的文本几乎一定是合法 UTF-8,摘要的随机字节几乎一定不是。严格 UTF-8 解码同时解决两个方向,并顺带修掉了 UTF-8 内容被按 Latin-1 显示成乱码的老问题。(图片魔数仍按原始字节判定。)

主窗口键盘状态机

键盘归属按焦点位置单点解析(js/keyboard-router.js::resolveKeyboardMode),优先级 codec > search > translation > list,先匹配先赢。

不按「面板是否可见」判定:键盘操作下焦点不会自己跑出侧栏,这样「只有反引号能切回列表」才成立;而鼠标点回中间列表能立刻把键盘交还列表。

模式 触发条件 路由拦下的键
codec 焦点在编解码面板内 反引号、Esc 关面板并把焦点还给列表
codec 内层 自定义下拉展开中 Esc 只收起下拉并 stopPropagation()
search 搜索框聚焦 Esc 逐级退出(清空 → 收起 → 隐藏窗口)
translation 焦点在翻译面板内(Shift+Tab 显式送入) Ctrl+Enter 翻译;Esc/Tab 交回焦点
list 默认 w/a/s/d、方向键、1-9/0、Enter、Space、Ctrl+P 等

焦点归属跟着条目走,不跟着索引走

focusedRow 只有两种含义:-1 是「没有焦点行」,0..N-1 指向当前可见项里的那一条。

  • 列表内容被释放(面板关闭、releaseMemory)时必须报 -1 而不是 0——空列表上的 0 是个不存在的行
  • prependClip 收到新条目时按 id 找回原来那一行,不做 focusedRow ± 1

两条规则缺一个,后果都是同一个用户可见症状:面板关着时到达的新条目把焦点挤到第二行,于是「截图复制完去贴图,贴出来的是上一张图」。

但「跟着条目走」只对握着焦点的面板成立。全局快捷键不抢焦点,所以它触发时 document.hasFocus() 为假就说明焦点行是上一轮会话的残影——此时一律问后端要最新一条。

「内层先吃掉、吃掉就不外泄」是这套状态机的通用规则:嵌套控件消费了某个键就必须 stopPropagation(),否则一次 Esc 会同时收下拉 + 关侧栏。反过来,路由拦下一个键之前也要确认动作真的发生了。

一个平台约束

主窗口里不允许出现原生 <select>:WebKitGTK 的原生下拉是独立 GTK 弹窗,一打开 webview 就失焦,看着像窗口崩掉。所有下拉都用 js/custom-select.js,并由 smoke 测试锁定 index.html 里 <select> 数量为 0。

列表行只取缩略图

库里存原图,列表行那格是 48 CSS px,所以列表渲染器走 get_clip_thumbnail(后端缩到最长边 128 px 并按 id 缓存),只有预览面板取 get_clip_image 原图。

退回原图后功能照样对,只是每开一次面板就把十几 MB 送进 webview 并做十几次全尺寸 PNG 解码,全落在 webview 那一个线程上。

Clone this wiki locally