起因:一个磁盘满了的下午
我的主力机是一台 MacBook Air,配置比较丐。用久了最大的焦虑不是性能,而是磁盘空间莫名其妙就满了。每次Finder里看还有几个G,转头装个依赖或者微信缓存一膨胀,系统就开始弹窗警告。
之前一直用腾讯的 Lemon,免费、界面清爽,轻点一下就能清缓存、看资源占用。但有个问题 —— 更新越来越慢,新系统的适配问题也没修,偶尔运行过程中还会卡死,最近才升级修复了这些问题。
iStat Menus 我也试过,功能确实专业,但订阅制对我这种需求简单的人来说有点重了。我只是想随时瞄一眼 CPU、内存、磁盘占用,偶尔看看哪个进程在偷吃资源,不想为一堆高级功能买单。
恰好前段时间有个朋友私信问我:"Tauri 除了写普通桌面应用,还能折腾点啥好玩的?"
我一想,菜单栏工具(menubar app)这种形态,不正是 Tauri v2 tray 能力可以覆盖的吗?与其继续等 Lemon 更新,不如自己搓一个,够用、好看、占资源少就行。
技术选型:为什么选 Tauri + Rust
一开始其实想过几种方案:
方案 A:纯 Swift + AppKit
这是最原生的做法,性能最好,和 macOS 的契合度也最高。但我 Swift 写得不多,而且如果以后想跨平台(比如顺手搞个 Linux 版),这套代码基本要重写。
方案 B:Electron
前端我熟,但 Electron 打包出来动辄上百兆,内存占用也感人。一个 7×24 挂在菜单栏的小工具用 Electron,有点杀鸡用牛刀的感觉。
方案 C:Tauri + Rust
最后选了这个。理由很简单:
• 前端用 React + TypeScript,界面想怎么画怎么画,Sparkline 趋势图、进程列表这些都能轻松实现。 • 后端用 Rust, sysinfo库能直接拿到 CPU、内存、网络、磁盘、进程、温度这些数据,API 设计得挺干净。• Tauri 打包体积小,Release 出来也就十几兆,启动快,内存占用也低。 • Tauri v2 对 tray(托盘/菜单栏)的支持比 v1 好很多,正好覆盖这个场景。
当然代价也有:Tauri 的 macOS 私有 API 需要手动开启,而且有些底层交互(比如后面要说的托盘图标原生绘制)还是得写 unsafe 的 Objective-C 调用。不过整体来说,这个组合是性价比最高的。
整体架构:一个没有主窗口的应用
这个应用从设计上就和其他桌面软件不一样——它没有主窗口。
tauri.conf.json 里 windows 是空的。唯一的窗口是一个叫 dropdown 的浮动面板,尺寸固定 380×448,无边框、不可调大小、透明背景。平时隐藏,点击菜单栏图标才弹出来。
Rust 侧启动时干这几件事:
1. 设置 ActivationPolicy::Accessory,这样应用不会出现在 Dock 栏,也不会在 Command+Tab 里露脸。2. 创建菜单栏图标(tray)。 3. 创建那个隐藏的 dropdown窗口。4. 起一个后台任务,定时采集系统数据。
这里踩了个坑:Accessory 策略的应用在 macOS 上收不到正常的 resignKey 通知。也就是说,你点桌面或者其他应用,窗口不会自动失焦。我一开始用 Tauri 的 onFocusChanged 监听,发现经常不触发,点桌面窗口还傻乎乎地挂在那儿。
最后的解决方案是装一个全局的 NSEvent monitor,监听鼠标点击事件。只要点击位置不在应用内,就把窗口收起来。这个 monitor 是用 objc2 直接调的 AppKit,需要包一层 unsafe,但胜在稳定可靠,用了之后再也没出现过窗口赖着不走的情况。
数据流:Rust 主动推,前端被动收
大部分 Tauri 应用的前端是通过 invoke 调 Rust 命令的。但我这个项目反过来了——前端完全不调用命令,所有数据都是 Rust 主动通过 Tauri 事件推给前端。
这么设计有两个原因:
1. 数据本来就是定时产生的(每 2 秒一次),前端轮询没意义。 2. 可以减少 IPC 开销。Rust 侧会判断面板是否可见,只有面板打开的时候才 emit 数据,收起来的时候后台只更新 tray 图标,不往前端发事件。
具体分两条流:
• metrics-update:每 2 秒一次,包含 CPU、内存、磁盘、网络、温度。• processes-update:每 5 秒一次,Top 10 进程列表。
前端用自定义 hook 监听这两个事件,维护一个环形缓冲区(RingBuffer),长度 30,刚好存最近 60 秒的数据,用来画 Sparkline 那种迷你趋势图。数据存在 useRef 里,不会频繁触发重渲染,性能上没什么压力。
系统信息采集:sysinfo 的几个实用技巧
Rust 生态里做跨平台系统信息采集,sysinfo 几乎是事实标准。它覆盖了 CPU、内存、网络、磁盘、进程、传感器温度,而且一直在活跃维护。
但用下来有几个细节需要注意,都是文档里不会重点强调的:
1. CPU 使用率需要"暖机"
sysinfo 的 refresh_cpu_usage() 第一次调用时返回的使用率全是 0。因为它内部需要两个时间点的采样才能算出差值。官方文档里有个 MINIMUM_CPU_UPDATE_INTERVAL 常量,我在 Collector 初始化时会手动 sleep 这个间隔,然后再刷新一次,这样拿到的数据才是准的。跳过这一步,你的 CPU 使用率会一直显示 0%,很迷惑。
2. 网络速率需要自己算 delta
sysinfo 给的是网络接口的累计收发字节数(total_received / total_transmitted),不是速率。所以你需要自己算:两次采样之间的差值除以经过的时间。实现时用 Instant 记录时间戳,每次 tick 算一次 bytes/sec。
3. 温度传感器标签不固定
macOS 上温度传感器很多,CPU 温度通常藏在某个 component 里。我的做法是遍历所有 component,找温度最高的那个,同时记录它的 label。如果 label 变了(比如插拔外设导致传感器列表变化),通过 watch::channel 通知前端更新显示。
4. 进程聚合:Chrome 不该出现 20 次
sysinfo 返回的进程列表是扁平的。macOS 上像 Chrome、VS Code 这种应用会开一堆 Helper 进程和 Renderer 进程。如果直接展示,列表会被同一个应用占满。
我的聚合逻辑分两步:
1. 先看进程的 executable path,如果能匹配到 .app/目录,就提取 bundle 名称(比如Google Chrome.app/Contents/...→Google Chrome)。2. 如果路径匹配不到,就 strip 常见后缀,比如 xxx Helper→xxx,xxx (Renderer)→xxx。
同名的进程把内存和 CPU 占用加起来,最后按内存排序取 Top 10。这样展示出来的列表清爽很多,一眼就能看出是哪个应用在吃资源。
托盘图标:用 AppKit 直接画原生图像
这是整个项目里最花时间、也是最有意思的部分。
Tauri 自带的 tray API 只能设置一个静态图标或者一段文字标题。但像 iStat 那种在菜单栏直接显示数字的能力,Tauri 没有原生支持。我调研了几种方案:
• 方案 1:前端生成图片传给 Rust。 用 canvas 画好,转成 PNG 字节传给 tray。但图片分辨率很难跟菜单栏的 Retina 屏幕对齐,文字容易糊。 • 方案 2:Rust 里用纯位图操作画。 理论上可以,但要对齐 macOS 的字体 metrics 非常麻烦,渲染质量也不如原生。 • 方案 3:直接调 AppKit。 用 objc2在 Rust 里调用NSImage、NSString、NSFont、NSBezierPath这些类,在lockFocus上下文里原生绘制。
我最后选了方案 3。核心思路:
1. 创建一个 2× 逻辑尺寸的 NSImage(逻辑尺寸 290×40,实际像素 580×80),这样 Retina 屏上文字是锐利的。2. 画 5 列数据:CPU%、内存%、磁盘%、温度、网速。上面一行是数值,下面一行是标签。 3. 列与列之间画细线分隔。 4. 画完后调用 setSize缩回逻辑尺寸,macOS 会自动把它当 2× Retina 图处理。5. 用 NSBitmapImageRep把图像导出成 PNG 字节。6. 传给 tauri::image::Image::from_bytes()更新 tray 图标。
字体大小、列宽、对齐方式都是反复调过的。上面数值行用 40pt(逻辑 20pt),下面标签行大部分用 28pt,只有网速那一列上下都用 40pt,因为网速标签是 ↓xxx 这种格式,需要和数值同宽才能对齐。
这个方案最大的好处是渲染完全走原生栈,字体是系统字体,颜色自动跟随菜单栏的亮/暗模式(whiteColor 会被系统自动反转)。缺点是代码全是 unsafe 的 msg_send! 宏,调试起来很费劲,一个 selector 写错直接 segfault,没有任何友好的错误提示。
窗口定位:别让面板飞出屏幕
点击菜单栏图标时,窗口要出现在图标正下方居中。Tauri 的 tray click 事件会返回 tray 图标在屏幕上的 rect,根据这个 rect 可以算出窗口的初始坐标。
但如果用户把图标放在屏幕最右边,窗口默认居中就会超出屏幕边界。所以要做边缘检测:
1. 先按"图标中心点下方"算初始坐标。 2. 用 monitor_from_point获取当前显示器信息,拿到屏幕尺寸和缩放因子。3. 如果窗口右边超出屏幕,就把 x 坐标贴到屏幕右边缘。 4. 如果窗口下边超出屏幕(比如图标在屏幕底部),就把窗口显示在图标上方而不是下方。 5. 左边也要兜底,x 不能小于 0。
实际测下来,macOS 菜单栏在不同分辨率、不同 Dock 位置、不同刘海屏机型上的表现都不太一样。这个逻辑最好多找几台机器试试,尤其是外接显示器和笔记本屏幕切换的场景。
性能优化:能省则省
菜单栏工具是长期驻留的,资源占用必须克制。我做的时候关注了几个点:
1. 可见时才发数据
Rust 侧每 2 秒采一次数据,但 emit 之前会先检查 dropdown 窗口是否可见。不可见就只更新 tray 图标,不往前端发事件。这样面板收起来的时候,前端完全休眠,CPU 和内存压力都很小。
2. 减少托盘图标的重绘
每次 tick 都会生成 tray 图像,但如果数值没有变化,其实没必要更新。我加了一个 last_tray_key 缓存,记录上一次生成的 (values, labels) 数组,只有当内容变了才调 set_icon。大部分时间 tray 上的数字变化并不频繁,这个优化能减少不少开销。
3. 硬件信息异步探测
内存品牌和磁盘型号这些信息,sysinfo 不提供,需要通过 system_profiler 命令获取。这个命令很慢,跑一圈要 1-2 秒。我把它放到独立的后台线程里跑,用 OnceLock 存结果,结果出来后通过 watch::channel 通知主循环更新。这样不会阻塞数据采集的定时器。
4. 前端避免频繁 setState
Sparkline 用的是 SVG path,数据存在 useRef 的 RingBuffer 里,通过 tick 计数器触发重渲染。这样不会每次来数据都复制大数组,30 个点的开销微乎其微。
发布与签名
macOS 应用要跑得顺畅,签名和公证是躲不开的。
Tauri 打包默认会用本地 keychain 里的证书签名,但有几个坑:
• macos-private-api需要开启,因为set_visible_on_all_workspaces属于私有 API。开启后应用不能上 Mac App Store,只能走 dmg 直接分发。• 如果用户第一次打开报"无法验证开发者",说明公证没做或者 Gatekeeper 拦截了。可以用 xattr -rd com.apple.quarantine临时绕过,但正式发布还是得走 Apple 的 Notarization 流程。
我的发布流程是 GitHub Actions 跑 bun tauri build,签好名后把 dmg 传到 Release 页面。小范围分发给几个朋友测了一圈,目前跑下来还算稳定。
总结:够用就好,vibe coding 的快乐
写这个小工具花了一天,但真正写代码的时间可能不到一半,剩下都在调各种细节 —— 字体大小差 2pt 在 Retina 屏上肉眼看得很明显;窗口弹出的位置在刘海屏和外接显示器之间表现不一样;进程聚合逻辑要覆盖各种奇奇怪怪的 macOS 应用命名……
但把这些细节打磨完,最后成品挂在菜单栏里,轻点一下就能看到自己电脑在干什么,那种 "这是我做的" 的满足感,跟用别人现成软件的感觉完全不同。
回头看,这个项目最大的收获不是技术深度,而是验证了 Tauri 在非传统桌面应用场景下的可能性。很多人以为 Tauri 只能写"带窗口的网页",但其实只要发挥一下想象力,菜单栏工具、系统级浮窗、后台守护进程这些形态都能做。
如果你也想折腾点类似的东西,我的建议是:
• 先定好边界。菜单栏工具很容易做复杂,功能越加越多。我给自己定的规矩是:只显示最核心 5 个指标,不加设置页,不加历史记录,不加通知推送。克制才能做好小工具。 • 不要怕写 unsafe。macOS 上有些需求(比如 NSEvent monitor、AppKit 绘制)Tauri 没有封装,该用 objc2就直接用,只要边界封装好,上层代码可以保持干净。
最后,如果你也在用 Lemon 或者 iStat,觉得有些地方不够顺手,不妨试试自己搓一个。Vibe coding 时代的乐趣不就在于 —— 不需要等别人更新,自己想要的自己造。