重新思考GNOME剪贴板问题

Lobsters Hottest 工具

摘要

本文分析了为什么GNOME剪贴板管理器由于GJS的单线程合成器导致卡顿,并介绍了Strata,一款新的剪贴板管理器,它将繁重的工作移出合成器线程,从而消除卡顿。

<p><a href="https://lobste.rs/s/ffs7xz/rethinking_gnome_clipboard_issues">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/05/27 07:29

# 重新思考 GNOME 剪贴板问题 来源:https://edu4rdshl.dev/posts/rethinking-the-gnome-clipboard-issues/ ## 引言 https://edu4rdshl.dev/posts/rethinking-the-gnome-clipboard-issues/#introduction 剪贴板管理器是那种你拥有时习以为常,失去后才意识到其重要性的工具之一。它是每个人日常工作中的基础功能和生活质量提升项,然而,有些桌面环境默认并不提供此功能。你复制了某样东西,接着复制了另一样,然后才发现你需要的是第一个。拥有历史记录功能很棒。但当历史记录开始损害你的图形会话时,情况就不妙了。 多年来,我在 GNOME 上用过几个剪贴板管理器,它们最终都导致了同样的问题:桌面会出现卡顿。复制截图时会有轻微卡顿。当打开历史记录且条目增长到几百个时,卡顿会更久。搜索时滞后于我的输入。这些在第一天都不是什么大问题,但随着我使用工具的时间越长,情况就越糟,这与你想要的完全相反。一些变通方法包括禁用图像历史、大幅限制可保留的文本量、限制条目数量等等。 我很早就开始思考这个问题。这并非易事,因为 GNOME 不暴露任何 `wlr-data-control` 协议,所以你不得不通过扩展来实现,而这带来了前面提到的所有问题。 这个周末,我决定编写 Strata,这是一个剪贴板管理器,它使用其他实现中都不存在的几种机制来解决这些问题。代码在 github.com/Edu4rdSHL/Strata (https://github.com/Edu4rdSHL/Strata),而这篇帖子的全部重点是我必须做对的一件事:卡顿不是你可以通过更快的循环或更小的缓存来解决的调优问题。这是架构问题。如果你想消除它,你必须将工作转移到合成器感受不到的地方。 ## 卡顿的来源 https://edu4rdshl.dev/posts/rethinking-the-gnome-clipboard-issues/#where-the-stutter-comes-from GNOME Shell 扩展运行在 GJS 中,GJS 是单线程的,并且这个单线程与整个合成器共享。绘制光标、动画工作区、合成每个窗口的同一个循环,也是你的扩展运行的循环。没有后台线程可以隐藏。无论你的扩展同步执行什么,桌面在它执行期间**都不会**做任何其他事情。 这对转发一次点击来说没问题。但对于剪贴板管理器实际执行的工作来说,这就不合适了。对负载进行哈希去重、执行 SQLite 查询、解码粘贴的 PNG 以便显示预览、在打开历史面板时构建数千行列表:每一项都是真实的 CPU 或 I/O 操作,其中的每一毫秒都是合成器冻结的一毫秒。你看到的就是卡顿。 随着历史记录的增长,情况会变得更糟,这总是让我烦恼。将整个历史记录加载到 JavaScript 内存中,持有一堆解码后的图像,每次打开时重新渲染长列表,通过遍历列表进行搜索:这些是 O(n) 的工作,甚至更差,而且是在还必须绘制屏幕的那个线程上。一个在二十个条目时流畅,在两千个条目时卡顿的剪贴板管理器,并不是真正的流畅。它只是内容为空而已。 这就是大多数 GNOME 剪贴板工具共有的模式。工作在扩展中运行,在合成器线程上,你唯一能调节的就是做多少工作以及多久做一次。Strata 从一个不同的前提开始:将工作完全移出那个线程,那么做多少工作的问题就不再重要了。 ## 两个组件,一个边界 https://edu4rdshl.dev/posts/rethinking-the-gnome-clipboard-issues/#two-components-one-boundary Strata 是两个进程,它们通过会话 D-Bus 通信,并且你需要两者。第一个是 `strata-daemon`,用 Rust 编写,基于 tokio 和 zbus。它负责所有重量级工作:一个 SQLite 数据库(使用 WAL 和 FTS5 全文索引)、内容去重、缩略图生成和搜索。它将所有这些作为一个名为 `dev.edu4rdshl.Strata` 的 D-Bus 服务暴露出来。第二个是 GNOME Shell 扩展 `[email protected]`,用 GJS 编写。它绘制顶部栏面板、运行搜索框、处理粘贴回写以及监视剪贴板中的新内容。这就是它的全部工作。它渲染 UI 并转发事件。它从不进行哈希处理,从不解码图像,从不接触 SQLite。 拓扑结构很简单: ``` GNOME Shell (GJS) --D-Bus--> strata-daemon --> SQLite (~/.local/share/strata) | +--> thumbnails (~/.cache/strata) ``` 这种拆分带来的代价是每次操作需要一次 IPC 跳转。实际上这很便宜,因为会话总线在同一台机器上共享内存速度快,而且正如你将看到的,扩展在任何重要的路径上几乎从不等待回复。你用这次跳转买到的是整个项目的前提:耗时的工作在单独的进程中运行,在线程池上,无论花费多长时间,合成器都不会感觉到。 扩展还监督守护进程。启用时,它生成二进制文件并监视它,如果守护进程死亡,则使用指数退避策略重新生成,只有在快速崩溃循环后才会放弃。如果守护进程已经在运行(例如,作为 systemd 用户服务启动的),扩展会检测到它并复用,而不是生成第二个副本。 ## 绝不在摄入路径上阻塞 https://edu4rdshl.dev/posts/rethinking-the-gnome-clipboard-issues/#never-block-the-ingest-path 剪贴板管理器最热门的路径是你复制东西的那一刻。如果这里出错,每次复制都会导致卡顿。Strata 的规则很简单:扩展即发即忘。当选择所有者发生变化时,扩展根据严格的允许列表检查 MIME 类型,读取字节,并调用 `SubmitItemAsync(mime, rawBytes)`。然后它立即返回。它不 `await` 结果。就扩展而言,复制在调用分派的那一刻就被记录了,然后合成器返回到它的帧。 一些细节使得这次调用很廉价。字节通过 D-Bus 作为原始字节数组 (`ay`) 发送,因此在 JavaScript 中没有编码步骤,在 Rust 中也没有解码步骤。并且前面有一个 50 毫秒的去抖处理,因为令人惊讶的是,许多应用程序在单次复制中会多次写入剪贴板,无需记录每一次。 在总线的另一端,守护进程执行实际工作,但它是在它应该属于的地方执行。每个数据库操作都在 `spawn_blocking` 内部运行,远离异步反应器: ```rust let conn = self.conn.clone(); tokio::task::spawn_blocking(move || { let guard = conn.lock(); // poison-recovering wrapper db::upsert_item(&guard, ...) // hash, dedup, thumbnail, prune }).await? ``` 在该闭包内部,守护进程使用 blake3 对负载进行哈希去重,执行以内容哈希为键的原子 upsert 操作(唯一索引意味着复制相同内容两次只会更新时间戳而不是创建重复行),如果内容是图像则进行解码并生成缩略图,并将历史记录修剪回其限制。这些操作都不在 D-Bus 反应器上运行,显然也不在合成器上运行。当磁盘和 CPU 工作在阻塞池上执行时,反应器保持响应,而桌面完全不知道发生了什么。 ## 懒加载,让历史记录大小不再重要 https://edu4rdshl.dev/posts/rethinking-the-gnome-clipboard-issues/#lazy-loading-so-history-size-stops-mattering 这是我最关心的部分,因为“流畅直到历史记录增长”正是我要消除的失败模式。Strata 有两个独立的懒加载层,在它们之间,你的历史记录大小不再是 UI 需要付出的代价。 第一层是分页元数据。面板使用 `GetHistory(offset, limit)` 请求历史记录,返回的只有元数据:id、MIME 类型、在 SQL 中截断到大约 200 个字符的简短文本预览、时间戳,以及一个标志表示条目是否有缩略图。它不返回完整内容。一页大型文本条目只需要几 KB 的 JSON,而不是几 MB。在 Rust 端,这些直接来自带有 `LIMIT` 和 `OFFSET` 的 `created_at DESC` 索引,因此无论表有多大,一页的查询都保持 O(log n) 的查找复杂度。面板在打开时加载一页,只有在滚动到距底部 200 像素以内时才加载下一页。完整表永远不会存在于 JavaScript 中。 模式围绕这种访问模式构建: ```sql CREATE TABLE clipboard_history ( id TEXT PRIMARY KEY, mime_type TEXT NOT NULL, content_text TEXT, -- text payloads content_blob BLOB, -- binary payloads thumbnail_blob BLOB, -- pre-decoded PNG, ~200 px content_hash TEXT NOT NULL, -- blake3 of the raw bytes created_at INTEGER NOT NULL ); CREATE INDEX idx_created_at ON clipboard_history (created_at DESC); CREATE UNIQUE INDEX idx_hash ON clipboard_history (content_hash); ``` 搜索是第二条路径,它不遍历列表。存在一个基于文本内容的 FTS5 全文索引,因此 `SearchHistory(query)` 是索引查找,而不是扫描。它返回匹配的结果集,面板以与分页显示最近视图完全相同的方式分页浏览该快照。分词器忽略变音符号并匹配前缀,因此搜索 `cafe` 可以找到 `café`,输入半个单词就能找到完整的。由于 FTS5 表使用外部内容,文本本身在基表中只存储一次,索引仅保存倒排数据。 ## 用于快速渲染的预览和缩略图 https://edu4rdshl.dev/posts/rethinking-the-gnome-clipboard-issues/#previews-and-thumbnails-for-fast-rendering 图像通常是剪贴板列表崩溃的地方,因为解码全分辨率的截图来绘制一个小预览,正是那种不应该在合成器线程上运行的工作。Strata 从不在那里做这件事。守护进程在摄入时,在阻塞池上,将每张图像解码并缩放到大约 200 像素的 PNG,并将该缩略图存储在数据库中。UI 从不解码全分辨率图像,从不。等到面板需要绘制图像行时,昂贵的部分已经在另一个进程中、在复制时完成了。 而且即使缩略图也不是在需要之前就传递的。`GetHistory` 不返回图像字节。每个图像行立即渲染一个占位符图标,然后 UI 执行以下两项操作之一:如果 `~/.cache/strata/thumbnails/<id>.png` 已经存在,则直接从磁盘加载;如果不存在,则调用 `GetThumbnail(id)` 一次,将 PNG 写入该缓存文件,并从此处加载。给定的缩略图在每个会话中最多获取一次;之后,重新打开面板时通过页面缓存读取它。 实际效果是,滚动经过一千个图像行,视口之外的所有内容不会产生任何 D-Bus 流量。你只为屏幕上的内容付出代价。 渲染本身也是节拍的。行通过 `GLib.idle_add` 以 20 个为一批进行添加,因此即使完整页面也是在多个空闲滴答之间构建的,而不是在一次阻塞爆发中构建的,帧之间总是有空间让下帧渲染。搜索框有 150 毫秒的去抖,并且有一个纪元计数器,这样如果你继续输入,来自旧查询的结果如果较晚到达,会被直接丢弃,而不是先渲染然后被替换。 ## 无阻塞的粘贴回写 https://edu4rdshl.dev/posts/rethinking-the-gnome-clipboard-issues/#paste-back-without-blocking-either 将条目放回剪贴板是 Strata 唯一需要完整内容的时刻,并且它会惰性地、直到那时才获取。面板调用 `GetItemContent(id)`,它返回 MIME 类型和原始字节作为 `(s, ay)`。再次是原始字节,因此合成器线程上无需解码 base64。从这里开始,根据类型进行拆分。文本通过 `St.Clipboard.set_text` 设置。二进制内容(包括图像)被包装在基于内存的选择源中,并成为剪贴板所有者: ```javascript const source = Meta.SelectionSourceMemory.new(mimeType, GLib.Bytes.new(bytes)); global.display.get_selection().set_owner( Meta.SelectionType.SELECTION_CLIPBOARD, source ); ``` 这种设计自然带来了一个安全属性。Strata 中没有代码路径会执行剪贴板内容。没有打开进程,没有打开 URI,剪贴板文本从不通过 Pango 标记处理。条目使用纯标签渲染,并作为不透明字节粘贴回去。存在于你历史记录中的恶意载荷无法获取任何东西,因为处理它的代码不解释它。 ## 安装 https://edu4rdshl.dev/posts/rethinking-the-gnome-clipboard-issues/#installing-it 如果你想尝试,README 中的安装部分 (https://github.com/Edu4rdSHL/Strata#install) 有最新的说明,我会在那里保持更新,而不是在这里。简单来说,有两个部分,守护进程和扩展,你需要两者。在 Arch 上,有 AUR 包(稳定版和 `-git` 通道);在其他系统上,通过 `make install-daemon` 和 `make install` 从源码构建,或者将守护进程作为 systemd 用户服务运行。之后,启用扩展并重新登录。README 列出了每种路径的详细步骤。 ## 结论 https://edu4rdshl.dev/posts/rethinking-the-gnome-clipboard-issues/#where-this-leaves-us 结果是一个剪贴板管理器,它在五十个条目时感觉和几千个条目时一样,这正是我想要的全部。这并不是因为工作比其他实现更快(虽然可以更快),而主要是因为真正慢的工作(哈希、图像解码、搜索和移动完整负载)从不运行在绘制屏幕的线程上,并且 UI 只拉取显示当前可见内容所需的区区几 KB。历史记录可以增长到你允许的大小,面板完全不在意。 还有一点值得提一下,如果你不在 GNOME 上:守护进程是与桌面无关的。它使用纯 D-Bus 通信,并内置了 `wl-clipboard-rs` 监视器,因此它可以在 wlroots 合成器(如 Sway 和 Hyprland)上独立运行,并通过不同的前端使用相同的接口。GNOME Shell 扩展只是我恰好需要的前端。 如果你想了解细节,仓库中的 ARCHITECTURE.md (https://github.com/Edu4rdSHL/Strata/blob/main/ARCHITECTURE.md) 比博客文章更深入地涵盖了存储模式、FTS5 查询构建、并发模型和安全边界。代码基于 GPL-3.0-or-later 许可。如果你尝试了并且出现卡顿,我真的很想知道,因为在这个项目中这是不被允许的(真的)。祝 Ctrl+C/Ctrl+V 愉快!

相似文章

Pegkits

Product Hunt

Pegkits 是一款防止剪贴板丢失的剪贴板管理器。

@VraserX: 来源:

X AI KOLs Following

关于 Gemini 3.5 Flash 检查点的用户说明指出,其速度提升,但提示词遵循度变差且界面臃肿,偏离了原始 Gemini 设计。

我们重写了 Ghostty GTK 应用程序

Mitchell Hashimoto

Mitchell Hashimoto 详细介绍了 Ghostty 的 GTK 应用程序重写,以完全拥抱来自 Zig 的 GObject 类型系统,从而提高了稳定性、功能和内存安全性,并通过 Valgrind 验证。

CacheTray

Product Hunt

CacheTray 是一款剪贴板工具,允许用户捕获内容,并通过单击将其直接发送到 Claude 或 ChatGPT。