用 Tauri 2 + Rust 写了个健康提醒工具,专治久坐不动
做过几个交互决策实验,选择越多,决策成本越高。开会的时候弹出提醒,你需要的不是「我要推迟多久」这个选择题,而是一个能快速点掉、不影响开会的按钮。固定 10 分钟足够用,又不需要动脑子。
起因
在某个下午三点,我意识到自己从早上九点坐下来就没怎么动过。腰有点酸,眼睛也有些干涩,但脑子里想着「就再处理完这个需求」,于是又继续盯着屏幕。等真正站起来的时候,后背已经僵了一大截。
这种情况应该不只是我一个人有。长期坐着写代码,颈椎腰椎一直保持同一个姿势,加上眼睛长时间聚焦近处,时间久了积累下来的问题不小。手机上有计时器,也有番茄钟 App,但每次都嫌麻烦,要么忘了开,要么被打断后忘了重置。我想要的其实很简单:一个安静地跑在后台、在合适的时间提醒我起来动动的桌面工具,不需要我主动维护什么状态。
找了一圈市面上的工具,要么功能太简陋,要么界面不对眼,要么根本就没有菜单栏倒计时。干脆自己写一个。
为什么选 Tauri,而不是 Electron
做桌面应用,Electron 是最常见的选择,Web 技术直接跑起来,生态也成熟。但 Electron 的包体积一直是个心病——一个 Hello World 应用动辄 100MB 以上,因为它打包了一个完整的 Chromium。对于一个健康提醒这种轻量级工具,这个开销实在说不过去。
Tauri 是近几年发展很快的替代方案。它的思路是:前端页面交给系统自带的 WebView(macOS 上是 WKWebView,Windows 上是 WebView2),后端逻辑用 Rust 写,通过 IPC 通信。这样打出来的包非常小,macOS 的 DMG 只有几 MB,而且 Rust 在系统调用、并发这些方面天然有优势。
这个项目对后端的需求很明确:一个持续运行的后台计时器、系统通知、托盘图标。这些都是系统级操作,用 Rust 处理比在 Node.js 里折腾要干净得多。Tauri 2 在 2024 年正式发布,API 也比 v1 稳定了很多,所以选它来做。
整体架构
应用分两层:
前端(React 19 + TypeScript + Vite)负责 UI 展示,包括倒计时环、运动库、设置面板三个页面。前端不持有任何计时状态,它只是监听后端发来的事件,以及在用户操作时调用 Tauri 命令。
后端(Rust + Tauri 2)负责所有核心逻辑:计时器循环、设置持久化、系统通知、托盘图标管理。
两者通过 Tauri 的 invoke/emit 机制通信。后端每秒 emit 一次 reminder-tick 事件,前端订阅后更新界面;当倒计时归零时,后端额外 emit reminder-fired,前端显示庆祝动画。
后端:Rust 的计时器循环
核心是一个 start_reminder_loop 函数,用 tokio::time::sleep 实现每秒 tick:
fn start_reminder_loop(app: AppHandle, state: Arc<AppState>) {
tauri::async_runtime::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(1)).await;
let (should_fire, style, dnd_start, dnd_end) = {
let settings = state.settings.lock().unwrap();
let mut rem = state.seconds_remaining.lock().unwrap();
if !settings.enabled {
let _ = app.emit("reminder-tick", *rem);
continue;
}
if *rem > 0 { *rem -= 1; }
let fire = *rem == 0;
if fire { *rem = settings.interval_minutes * 60; }
(fire, settings.style.clone(), settings.dnd_start, settings.dnd_end)
};
// ... emit tick, handle notification
}
});
}
每次 tick 拿到锁、判断状态、释放锁,逻辑集中在一处,没有竞争问题。AppState 用 Arc<Mutex<>> 包裹,在多个 Tauri command handler 和计时器之间共享。
免打扰时段的判断也在这里处理。设置了 DND 窗口(比如 22:00 到 08:00)之后,即使倒计时归零,也不会触发通知。DND 还要处理跨午夜的情况:
fn is_dnd_active(start: u32, end: u32) -> bool {
use chrono::Timelike;
let now = chrono::Local::now().hour();
if start <= end {
now >= start && now < end
} else {
// 跨午夜:比如 22 → 08
now >= start || now < end
}
}
这个细节如果处理不对,设置了夜间免打扰之后,凌晨反而会一直响,挺影响体验的。
设置持久化用了最简单的方式——JSON 文件,存在 Tauri 管理的 app data 目录下。读取时如果文件不存在或格式有问题,直接 fallback 到默认值,不会崩溃:
fn load_settings(app: &AppHandle) -> Settings {
let path = settings_path(app);
std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
菜单栏实时倒计时

这是整个应用里我最喜欢的一个细节。托盘图标不只是一个静态的小图标,而是每秒更新一次,直接显示距下次提醒还有多久:
🌿 43:27
鼠标悬停时 tooltip 会显示更完整的文字:「健康提醒 — 距下次提醒 43:27」。
这样不需要打开主窗口,一眼就能知道现在的状态。实现上每次 tick 都调用 set_title 和 set_tooltip:
fn update_tray_label(app: &AppHandle, seconds_remaining: u32) {
let m = seconds_remaining / 60;
let s = seconds_remaining % 60;
let title = format!("🌿 {:02}:{:02}", m, s);
let tooltip = format!("健康提醒 — 距下次提醒 {:02}:{:02}", m, s);
if let Some(t) = app.tray_by_id("main") {
let _ = t.set_title(Some(&title));
let _ = t.set_tooltip(Some(&tooltip));
}
}
macOS 上 set_title 会在菜单栏图标旁边显示文字,效果很干净。Windows 上则主要靠 tooltip。
托盘菜单里放了「显示主窗口」「暂停/恢复」「推迟 10 分钟」「退出」几个快捷操作,不用打开窗口就能控制。
前端:SVG 倒计时环

主页的核心是一个 SVG 画的圆形进度环,随着时间流逝顺时针消耗:
const circumference = 2 * Math.PI * radius;
const dashOffset = circumference * (1 - progress);
<circle
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
transform={`rotate(-90 ${size / 2} ${size / 2})`}
style={{ transition: 'stroke-dashoffset 0.8s ease' }}
/>
strokeDashoffset 控制已「消耗」的部分,rotate(-90) 让进度从 12 点位置开始。加上 transition: stroke-dashoffset 0.8s ease,每次 tick 之间的过渡就是平滑的,不会出现跳变。
倒计时数据来自后端 emit 的事件,前端 useCountdown hook 订阅:
useEffect(() => {
const tickUnsub = listen<number>('reminder-tick', (e) => {
setSecondsRemaining(e.payload);
});
const firedUnsub = listen('reminder-fired', () => {
setJustFired(true);
setTimeout(() => setJustFired(false), 3000);
});
return () => {
tickUnsub.then(fn => fn());
firedUnsub.then(fn => fn());
};
}, []);
前端完全不做计时,只是渲染后端推过来的秒数。这样就算用户关掉窗口再打开,倒计时状态也是准确的,因为真正的状态在 Rust 进程里。
通知文案:三种风格
提醒通知是使用者最直接感知的部分。做成三种风格可以切换:温柔陪伴、搞笑整蛊、简洁严肃。
温柔陪伴示例:
「眼睛需要休息 👀」
盯着屏幕太久了,抬头望向20米外,保持20秒,眼睛会感谢你
搞笑整蛊示例:
「警告:检测到久坐生物 🤖」
系统检测到您已化身为椅子附属品,请立即执行:站起来 → 伸懒腰 → 喝水
简洁严肃示例:
「起身」
久坐伤身。立即站起来,原地踏步60秒。
每种风格各写了 5 条,每次触发时用 subsec_nanos 取模来选,避免每次都弹同一条。这不是真正的随机,但对于「避免通知内容重复」这个目标已经够用了——相邻两次触发的 subsecond 差异足够大,连续看到同一条的概率很低。
用系统原生通知而不是应用内弹窗,是因为系统通知在窗口隐藏时也能显示,不会因为用户专注工作、窗口在后台就漏掉。macOS 和 Windows 的通知中心都支持,tauri-plugin-notification 封装好了两边的差异。
运动库

光提醒还不够,有时候站起来却不知道做什么,就很容易直接坐回去。所以加了一个运动库,14 个动作,覆盖四类:
- 椅上运动:坐姿收腹、坐姿扭腰、踮脚尖、坐姿抬腿等——不需要离开座位,适合开会间隙
- 站立运动:深蹲、原地踏步、全身伸展——真正活动到大肌群
- 眼部放松:远近交替看、眼球转动练习——对长时间盯屏幕的人特别有用
- 呼吸练习:腹式呼吸、4-7-8 节奏——缓解焦虑、调节状态
每个动作卡片包含说明文字和建议次数/时长,点击后展开详情。这些内容是静态数据,写在 src/data/exercises.ts 里,不走后端。
设置面板

设置只有几个核心参数:
- 提醒间隔:1 到 120 分钟,滑块调节
- 提醒开关:暂时不想被打断时关掉,倒计时环显示「已暂停」
- 免打扰时段:两个时间点,支持跨午夜(比如 22:00 到 08:00)
- 通知风格:三种切换
设置在前端做乐观更新——用户拖动滑块时界面立即响应,点「保存设置」后才写入后端。这样交互不会卡顿:
const updateSettings = useCallback((patch: Partial<Settings>) => {
setSettings(prev => ({ ...prev, ...patch }));
}, []);
const saveSettings = useCallback(async () => {
setSaving(true);
try {
await invoke('save_settings', { settings });
} finally {
setSaving(false);
}
}, [settings]);
保存时后端除了写 JSON 文件,还会重置倒计时到新的间隔值,所以改完间隔保存后会立即生效,不需要重启应用。
关闭窗口不退出
这个行为很多工具做不对,或者做了但逻辑有 bug。期望的效果是:点击窗口关闭按钮,窗口消失,但应用继续在后台跑,提醒不中断。要彻底退出,需要从托盘菜单点「退出」。
Tauri 里拦截关闭事件:
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = window.hide();
}
})
prevent_close() 阻止默认的关闭行为,然后手动 hide() 窗口。托盘左键点击或选择「显示主窗口」时,show() + set_focus() 把窗口找回来。
有一个要注意的地方:macOS 上如果不在 tauri.conf.json 里设置 "activation_policy": "accessory",Dock 里会有一个应用图标,每次点托盘唤起窗口时 Dock 图标也会跳。对于这种常驻后台的工具,Dock 里不需要有图标,改掉之后体验干净很多。
打包和 CI
本地打包命令:
npm run tauri build
产物在 src-tauri/target/release/bundle/ 下,macOS 生成 .app 和 .dmg,Windows 生成安装包。
CI 用 GitHub Actions,分了两个 job:先跑 create-release 创建 GitHub Release,再并行跑 macOS 和 Windows 的构建,把产物上传到 Release。macOS 需要 Rust + Node 环境,Windows 用 NSIS 打包(WiX 在 GitHub Actions 的 Windows runner 上有坑,light.exe 经常找不到,换 NSIS 就好了)。
推 tag 之后 CI 自动触发,大概十几分钟出包,两个平台的安装包都会挂到 Release 页面上。
踩过的几个坑
Mutex 死锁。早期的计时器代码里,在同一个作用域内先拿了 settings 锁又试图拿 seconds_remaining 锁,在某些路径下会死锁。解决方法是把两个锁的操作放在同一个块里,统一拿、统一放,不要交叉持有:
let (should_fire, style, ..) = {
let settings = state.settings.lock().unwrap();
let mut rem = state.seconds_remaining.lock().unwrap();
// 做完所有操作
(fire, settings.style.clone(), ...)
}; // 两个锁在这里同时释放
托盘 title 在 Windows 上不显示。set_title 是 macOS 特有的功能,Windows 托盘不支持在图标旁显示文字。所以 Windows 上主要靠 tooltip(鼠标悬停时显示),体验上有差别,但没有更好的跨平台方案。
第一次运行时通知权限。macOS 上第一次发通知时,系统会弹权限请求。如果用户点了「不允许」,之后的通知就会静默失败。tauri-plugin-notification 提供了权限查询 API,但在这个工具里没有做强引导,只是在发测试通知时如果没有权限会看到没有通知出现——这个体验还有改进空间。
DND 跨午夜逻辑写错过一次。最初的判断是 now >= start && now < end,对于 22:00→08:00 这种跨午夜的范围完全不生效。加了 start > end 时的特殊分支才修好,测试时要专门跑一遍边界情况。
设计上的一些取舍
为什么不做番茄钟。番茄钟需要用户主动开始每个 session,需要主动维护「工作/休息」状态切换。这个工具的定位是「无感知的后台提醒」,开机自启,然后就不用管它了。要降低使用摩擦,就不能要求用户每次手动操作。
为什么推迟只有 10 分钟,不能自定义。做过几个交互决策实验,选择越多,决策成本越高。开会的时候弹出提醒,你需要的不是「我要推迟多久」这个选择题,而是一个能快速点掉、不影响开会的按钮。固定 10 分钟足够用,又不需要动脑子。
为什么前端不持有计时器状态。如果在前端也跑一个 setInterval,那关闭窗口再打开时,前端的计时器就重置了,显示的时间和真实状态会对不上。把状态完全放在 Rust 进程里,前端只负责显示,状态永远一致。
样式选择。整体用了一套暖色调的 organic minimalism 风格,主色是绿色系,没有强烈的视觉冲击感。健康类工具应该让人看着舒服,而不是像 dashboard 那样信息密度很高、到处是数字。颜色用的是 CSS oklch() 色彩空间,比传统的 hex 更容易做出视觉上协调的配色系列。
现在的状态
应用已经在 GitHub 开源,支持 macOS(Apple Silicon 和 Intel 两个版本)和 Windows。CI 每次推 tag 自动打包,在 Releases 页面可以直接下载 DMG 或 Windows 安装包。
macOS 首次打开可能会遇到「无法验证开发者」的提示,这是因为没有付费的 Apple 开发者证书来签名,右键点击 App 选「打开」就能绕过这个限制。
代码量不大,核心的 Rust 后端加起来不到 350 行,前端几个组件也都比较独立,对想学 Tauri 的人来说应该是个不错的参考项目。
如果你也是久坐工作者,可以去 Releases 页面 下载试试。有问题或者建议,欢迎开 Issue。
更多推荐


所有评论(0)