Repository navigation
Architecture Overview
| 层 | 技术 | 拥有什么 |
|---|---|---|
| 主窗口 | 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 金图锁定,预览与导出不会不一致。
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 那一个线程上。