起因

在某个下午三点,我意识到自己从早上九点坐下来就没怎么动过。腰有点酸,眼睛也有些干涩,但脑子里想着「就再处理完这个需求」,于是又继续盯着屏幕。等真正站起来的时候,后背已经僵了一大截。

这种情况应该不只是我一个人有。长期坐着写代码,颈椎腰椎一直保持同一个姿势,加上眼睛长时间聚焦近处,时间久了积累下来的问题不小。手机上有计时器,也有番茄钟 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 拿到锁、判断状态、释放锁,逻辑集中在一处,没有竞争问题。AppStateArc<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_titleset_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 封装好了两边的差异。


运动库

运动库 ![运动库](https://i-blog.csdnimg.cn/img_convert/ceea0ab999b23a5394f46862d705f94c.png)

光提醒还不够,有时候站起来却不知道做什么,就很容易直接坐回去。所以加了一个运动库,14 个动作,覆盖四类:

  • 椅上运动:坐姿收腹、坐姿扭腰、踮脚尖、坐姿抬腿等——不需要离开座位,适合开会间隙
  • 站立运动:深蹲、原地踏步、全身伸展——真正活动到大肌群
  • 眼部放松:远近交替看、眼球转动练习——对长时间盯屏幕的人特别有用
  • 呼吸练习:腹式呼吸、4-7-8 节奏——缓解焦虑、调节状态

每个动作卡片包含说明文字和建议次数/时长,点击后展开详情。这些内容是静态数据,写在 src/data/exercises.ts 里,不走后端。


设置面板

设置 ![设置](https://i-blog.csdnimg.cn/img_convert/03072114d9a6b09495391a215068a140.png)

设置只有几个核心参数:

  • 提醒间隔: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。

Logo

葡萄城是专业的软件开发技术和低代码平台提供商,聚焦软件开发技术,以“赋能开发者”为使命,致力于通过表格控件、低代码和BI等各类软件开发工具和服务

更多推荐