用 Wails 做一个剪贴板历史工具

要做什么

做一个剪贴板历史工具:后台自动记录每次复制的内容,历史列表可搜索,点条目把内容回填到剪贴板并收起窗口。三句话讲完,但”剪贴板”这个约束会让每个设计决策都变重——它没有变更通知,内容可能不是文本,甚至可能是刚复制的密码。工具形态定为常驻:点关闭不退出,再双击图标能把窗口唤回来。

验收指标先把丑话说在前面,每条都带测法:

指标 目标值 怎么测
复制到出条目延迟 不迟于一个轮询周期(500ms) 连续复制 10 次不同文本,每次 1 秒内条目出现、无漏记
去重正确性 同内容只留一条且置顶到最新 连续复制同一文本 5 次,历史里只有 1 条、时间最新
容量与截断 历史上限 100 条,单条截断 5000 字符 高强度复制后数条数;粘一整篇文章进去看是否截断
关闭与唤回 点关闭窗口消失、进程在;再双击 exe 窗口回来 按流程手工走两遍
数据竞争 go test -race 零报告 历史管理逻辑抽成独立包跑 -race,绑定层手工操作验证
重启恢复 杀进程再启动,历史还在 记录 3 条后结束进程,重启核对列表

整体架构

这个应用的职责分界很清楚:采集是 Go 的活,展示是前端的活,中间靠绑定与事件连起来。

系统剪贴板(OS)
    │ 每 500ms 由 Go 后台 goroutine 读取
    ▼
Go 后端
 ├─ 轮询器:ticker + ClipboardGetText,异常静默跳过
 ├─ History:mutex 保护的切片(去重、截断、上限)
 └─ 持久化:JSON 文件,退出与每次变更后落盘
    │ Bind 暴露 GetHistory/Restore/ClearAll
    │ EventsEmit 广播 history:updated
    ▼
WebView 前端
 ├─ 历史列表 + 搜索框(只消费 Go 给的数据)
 └─ 点条目 → Restore() 回填剪贴板并隐藏窗口

选型上刻意克制:持久化用单个 JSON 文件而不是 SQLite——百条上限、每条几 KB 的数据量,JSON 读写是毫秒级,引入数据库属于给工具箱里的扳手盖一栋仓库。什么时候该换 SQLite:条数上千、需要按内容全文检索、或者开始存结构化的多类型条目。同理,不需要给采集器上 worker 池——剪贴板一次只有一个内容,单 goroutine 轮询是恰好正确的复杂度。事件、绑定这两个机制的原理见《Wails 框架要点》,本文只讲在这个具体场景里怎么用。

难点一:剪贴板没有”变更事件”

这是整个工具的地基问题:操作系统剪贴板不会主动通知你”内容变了”,一切方案都在解决”怎么感知变化”。

方案 优点 缺点 结论
监听系统剪贴板消息(如 Windows 的剪贴板监听窗口) 零延迟、零空转 各平台 API 完全不同,要 CGO 与三份平台代码;Wails v2 未内置 不采用,成本远超收益
Go 侧定时轮询 三个平台同一份代码;窗口隐藏也照常采集 有轮询间隔延迟;空转调用开销 采用,500ms 延迟对”复制后粘贴”场景无感
前端 setInterval 调绑定方法轮询 代码集中在一处 WebView 隐藏或失焦时 JS 定时器可能被节流,正是常驻工具的主要工况,会漏记;每次轮询多跨一次 JS→Go 调用 反模式,不采用

轮询必须放在 Go 侧而不是前端 JS 里,这是本工具最容易被写错的一处。实现如下,注意退出通道——常驻 goroutine 必须能被干净地取消:

// 在 OnStartup 里调用,ctx 存入结构体供运行时 API 使用
func (a *App) startPolling(ctx context.Context) {
    a.ctx = ctx
    ticker := time.NewTicker(500 * time.Millisecond)
    go func() {
        defer ticker.Stop()
        for {
            select {
            case <-ctx.Done(): // OnShutdown 后 ctx 取消,goroutine 退出
                return
            case <-ticker.C:
                a.checkClipboard()
            }
        }
    }()
}

反模式点名:把轮询器写成 for { ... time.Sleep(500ms) } 的裸循环且不接 ctx——应用退出后 goroutine 还在,退出行为变得不可预测。另外轮询间隔别压到 50ms 级别求”实时”:每次调用都要进系统剪贴板 API,收益只有几十毫秒延迟的差别,代价是无谓的唤醒次数,500ms 到 1s 是这个场景的合理区间。

难点二:轮询 goroutine 与绑定方法的并发安全

采集 goroutine 每 500ms 写一次历史,前端的 GetHistory、Restore、ClearAll 随时读写同一个切片——不设防就是数据竞争,轻则列表错乱,重则 panic。方案对比:

方案 优点 缺点 适用
sync.Mutex 保护切片 直观,临界区小,改动点集中 忘了加锁就出错,靠 -race 兜底 采用,状态共享型场景的标准解
单个 goroutine 独占 + channel 传指令 无锁,所有权清晰 查询类操作也要走往返,前端调用延迟增加 写入极频繁、读取少的场景才划算
sync.RWMutex 读多写少时并行读 本场景读都在毫秒内完成,收益可忽略 列表上千条且高频读时再演进

百条量级的数据,Mutex 就是终点方案。核心代码:

type Entry struct {
    Text      string `json:"text"`
    CreatedAt int64  `json:"created_at"` // Unix 毫秒,前端负责格式化
}

const (
    maxItems  = 100
    maxRunes  = 5000 // 按"字符"截断而非字节,中文不会被腰斩
)

func (a *App) checkClipboard() {
    text, err := runtime.ClipboardGetText(a.ctx)
    if err != nil || text == "" {
        return // 剪贴板里是图片/文件等非文本内容,跳过而不是记空条目
    }
    runes := []rune(text)
    if len(runes) > maxRunes {
        text = string(runes[:maxRunes]) // 防超大文本把内存与 UI 一起拖垮
    }
    a.mu.Lock()
    defer a.mu.Unlock()
    // 同内容去重:已存在就先移除,等价于"置顶到最新"
    for i, e := range a.history {
        if e.Text == text {
            a.history = append(a.history[:i], a.history[i+1:]...)
            break
        }
    }
    a.history = append([]Entry{{Text: text, CreatedAt: time.Now().UnixMilli()}}, a.history...)
    if len(a.history) > maxItems {
        a.history = a.history[:maxItems]
    }
    runtime.EventsEmit(a.ctx, "history:updated") // 增量通知,前端拉全量列表
}

两个反模式点名。其一,”先查再插”防重:先遍历判断不存在再 append,两步之间轮询 goroutine 和绑定方法可能交错,重复条目照样出现——去重和写入必须在一个临界区里完成,上面代码正是这么做的。其二,去重比对用原始文本:某些应用复制时会在末尾带一个不可见换行,肉眼相同的内容就变成了两条,比对前先做规范化处理(去首尾空白)能消掉一大类”灵异重复”。

验证手段:go vet ./... 加上把历史管理抽成独立包后 go test -race ./...——用两个 goroutine 并发调用增删查跑几百次,-race 报告干净才算过。并发原语的地基见《Go 并发基础》。

难点三:回贴、落盘与”明文存储”的边界

点条目回贴剪贴板并收起窗口,是这类工具的核心交互,回贴即隐藏在体感上才像”系统级”工具:

func (a *App) Restore(index int) error {
    a.mu.Lock()
    if index < 0 || index >= len(a.history) {
        a.mu.Unlock()
        return fmt.Errorf("index out of range: %d", index)
    }
    text := a.history[index].Text
    a.mu.Unlock()

    if err := runtime.ClipboardSetText(a.ctx, text); err != nil {
        return err
    }
    runtime.Hide(a.ctx) // 回贴完成即收起,用户的下一步永远是粘贴
    return nil
}

持久化选了最朴素的路线:每次变更后把整个历史写成 JSON 文件,存到用户配置目录,启动时读回。百条量级下这个量级的开销可以忽略,换 SQLite 的时机前面已经说清。代码里值钱的是边界意识而不是写文件本身:

func historyPath() (string, error) {
    dir, err := os.UserConfigDir() // Windows 是 AppData,macOS 是 ~/Library
    if err != nil {
        return "", err
    }
    return filepath.Join(dir, "cliphist", "history.json"), nil
}

必须诚实面对的边界:剪贴板历史等于把你复制过的密码也明文存了一份。缓解手段按投入排序:复制内容超过一定长度才入历史、提供”清除全部”的一键入口(本文实现了)、对来自密码管理器的内容做排除——最后一条没有可靠通用的判断方法,因为多数密码管理器复制后本来就会自动清剪贴板,这是既有生态的现实约束。这是工具与隐私的真实代价,写进 README 比藏在代码里更有价值。

难点四:常驻与唤回——v2 没有系统托盘

“常驻”碰到 v2 的能力边界:没有系统托盘,也没有全局快捷键(托盘是 v3 alpha 才在规划的能力,本文不依赖它)。可用方案对比:

方案 说明 结论
HideWindowOnClose + SingleInstanceLock 点关闭改为隐藏进程常驻;再双击 exe 由单实例锁回调唤回窗口,均为 v2 已发布能力(后者 v2.7.0 起) 采用,三平台行为一致
系统托盘 v2 无此能力 不可用,产品要求必须有托盘就别选 v2
全局快捷键 v2 无内置,需要第三方平台库 不引入,破坏”零 CGO”的简洁性
err := wails.Run(&options.App{
    Title:  "ClipHist",
    Width:  520,
    Height: 640,
    HideWindowOnClose: true, // 点关闭 = 隐藏,进程继续采集
    SingleInstanceLock: &options.SingleInstanceLock{
        UniqueId:               "b1f0d3a2-4c5e-4f6a-8b7c-cliphist-uuid",
        OnSecondInstanceLaunch: app.onSecondInstanceLaunch,
    },
    OnStartup: app.startup,
    OnShutdown: func(ctx context.Context) {
        app.save() // 退出前落盘
    },
    Bind: []interface{}{app},
})
// 第二次双击 exe 走到这里:官方文档明确回调不会自动聚焦窗口
func (a *App) onSecondInstanceLaunch(data options.SecondInstanceData) {
    runtime.WindowUnminimise(a.ctx)
    runtime.Show(a.ctx)
}

反模式点名:自己用锁文件实现单实例——进程崩溃后残留的锁文件会把应用锁在门外,而 SingleInstanceLock 在 Windows/macOS/Linux 上分别用系统命名互斥体与通知机制实现,进程死了锁就释放。轮子就在那里,而且是跨平台一致的。

验证

分阶段定位表,覆盖实现过程中真实会卡住的环节:

阶段 现象 根因在哪 怎么改
采集 复制后条目不出现 轮询没启动(OnStartup 前调用)或非文本内容报错被吞 先在轮询回调里打一行 LogDebug 确认周期在走,再看 GetText 错误分支
去重 肉眼相同的内容出现两条 复制源带了不可见字符(尾部换行、零宽字符) 比对前规范化:strings.TrimSpace 起步,仍复现再查零宽字符
关闭 点关闭进程直接退了 HideWindowOnClose 没配或拼错字段 对照 options.App 字段名检查配置
唤回 双击 exe 没反应(尤其 Linux) 回调只传数据不聚焦窗口;Linux 窗口管理器可能拒绝抢焦点 确认 WindowUnminimise + Show 已写;Linux 上属平台行为,点任务栏图标
性能 列表滚动卡顿 前端一次性渲染全部条目或大文本未截断 截断上限兜底后,前端改虚拟滚动或分页

完整验收按开头的指标表逐条过:延迟与去重用手工复制序列测,重启恢复杀进程验证,-race 报告作为并发安全的硬门槛——这条过不了,其他都白测。

数字与边界

本篇未做定量实测,以下为定性口径:产物体积十几 MB 量级(与开山篇一致,不含 Chromium 是根本原因);常驻内存几十 MB 量级,大头是系统 WebView 而非 Go 进程;500ms 轮询的空转调用在任务管理器里观察不到可感的 CPU 变化。均以你机器的实际表现为准。

天花板也交代清楚:这个实现不做全文检索(百条量级前缀过滤够用)、不做多格式条目(图片历史需要另一套采集与存储)、不承诺密码安全(明文落盘的边界如实告知)。再往上一档,评估的是 SQLite + FTS 与系统钩子采集,那是另一个量级的工程。

总结

这个项目的四条硬收获:剪贴板历史工具的本质是”把易失状态变成常驻状态”,难点全在采集感知与并发安全,UI 是最薄的一层;Go 侧轮询而不用前端定时器,是被 WebView 节流行为逼出来的正确决策,也再次说明桌面应用里”JS 侧尽量薄”的分量;去重与写入必须在同一临界区,”先查再插”的直觉在并发下必错;v2 没有托盘与全局热键,产品形态要求这两样时要换框架而不是硬凑——Wails 的能力边界在开山篇的取舍表里,补齐它需要等 v3。

API 细节随查随用的字典见《Wails 开发参考字典》;从零起步的完整路径见《Wails 学习路线》。

版本核实(以官方文档为准):写作时 Wails v2 最新稳定版为 2.9.2(2024 年 9 月发布);本文使用的 ClipboardGetText/ClipboardSetText、HideWindowOnClose、WindowSetAlwaysOnTop(v2.6.0 起)、SingleInstanceLock(v2.7.0 起)、EventsEmit、Show/Hide 均为 v2.9.x 已发布行为;v2 无系统托盘与全局快捷键、v3 尚在 alpha,均与官方仓库与文档现状一致。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇