Tauri + React + Rust 构建轻量级桌面番茄钟:从技术选型到打包分发
1. 项目概述:为什么选择 Tauri + React + Rust 来造一个番茄钟?
最近在折腾桌面端开发,想做个自用的番茄钟工具。市面上同类软件不少,但要么功能太臃肿,要么界面不合心意,要么就是隐私上总有点不放心。作为一个有点“技术洁癖”的开发者,我琢磨着不如自己动手造一个,正好也试试看最近挺火的Tauri这套技术栈。
这个项目的核心目标很明确:一个运行在 Windows 上的、轻量级的、完全本地的番茄钟应用。它需要美观、响应快、不联网、不吃资源,并且能让我在写代码时顺手就用。基于这些需求,我放弃了传统的 Electron,选择了Tauri + React + Rust的组合。简单来说,前端界面用 React 来构建,享受其成熟的组件生态和开发体验;而应用的核心逻辑和系统交互则交给 Rust,它带来的高性能和内存安全是桌面应用的绝佳保障;最后,Tauri 作为胶水,将这两者优雅地粘合在一起,并生成一个极其精简的安装包。
你可能听过“vibe coding”这个词,它描述的是一种沉浸、流畅、跟着感觉走的编码状态。这个项目就是我的一次“初试”,整个过程没有太复杂的架构设计,更多的是跟着直觉和需求走,快速验证想法,享受构建一个完整可用的桌面工具的过程。如果你也对 Rust 或 Tauri 感兴趣,或者想拥有一个完全属于自己的效率工具,那么跟着这篇记录,你应该能复现出一个属于你自己的番茄钟。
2. 技术栈选型与核心思路拆解
2.1 为什么是 Tauri 而不是 Electron?
这是第一个要回答的问题。Electron 大名鼎鼎,用 JavaScript 一套代码搞定跨平台桌面应用,生态庞大。但对于一个番茄钟这样功能单一、对性能敏感(尤其是内存占用和启动速度)的工具来说,Electron 显得有些“杀鸡用牛刀”。
Electron 每个应用都内嵌了一个完整的 Chromium 浏览器内核,这导致即使是一个“Hello World”应用,打包后体积也轻松超过 100MB,运行时内存占用常驻几百 MB。而 Tauri 采用了完全不同的思路:它使用操作系统的原生 WebView(在 Windows 上是 WebView2)来渲染界面。这意味着你的应用界面是由系统提供的组件渲染的,应用本身只需要包含你的前端代码和 Rust 后端逻辑。最终结果就是,一个简单的 Tauri 应用打包后可能只有几 MB,内存占用也远低于 Electron 应用。
对于番茄钟这种需要常驻后台、偶尔提醒、且希望尽可能少打扰用户(即少占资源)的工具,Tauri 的轻量特性是决定性优势。此外,Tauri 的 Rust 后端提供了极强的系统级能力,比如更精细的进程控制、系统托盘图标、原生菜单、以及直接调用系统 API,这些都比 Electron 的 Node.js 后端在某些方面更强大和安全。
2.2 React 作为前端的必然性
选择 React 几乎是顺理成章的。我需要一个组件化、声明式的 UI 框架来快速构建交互界面。番茄钟的界面元素并不复杂:一个倒计时显示器、几个控制按钮(开始、暂停、重置)、一个任务列表、以及可能的历史统计图表。React 及其庞大的生态系统(如状态管理、组件库)能让开发效率倍增。
使用 Vite 作为构建工具,配合 React,可以获得极快的热更新速度,这对于 UI 调试至关重要。Tauri 官方也完美支持 Vite,集成起来非常顺畅。前端部分我选择了shadcn/ui组件库搭配 Tailwind CSS,这样既能保证 UI 的美观和一致性,又能保持极致的灵活性,不需要引入沉重的全量组件库。
2.3 Rust 带来的底气与挑战
Rust 是这个技术栈的灵魂,也是最大的挑战和乐趣所在。番茄钟的核心逻辑,比如精确的计时器、任务数据的持久化存储、系统通知的触发等,都放在 Rust 后端。
- 性能与安全:计时器需要精确,不能有大的漂移。Rust 可以轻松实现高精度的计时逻辑。数据存储涉及到读写文件,Rust 的所有权系统和错误处理能极大避免数据竞争或损坏。这些都是构建可靠桌面应用的基石。
- 系统集成:通过 Tauri 提供的 API,Rust 可以方便地创建系统托盘图标、发送原生系统通知(即使应用窗口最小化)、甚至注册全局快捷键(比如
Ctrl+Shift+P快速开始一个番茄)。这些能力让应用体验更接近原生。 - 挑战:对于前端开发者来说,Rust 的学习曲线是客观存在的。但其严谨性在项目规模增长时会变成巨大的优势。在这个小项目中,我们只会用到 Rust 非常基础的部分,Tauri 也做了很好的封装,入门门槛并不高。
这个组合的最终形态是:React 构建的漂亮界面运行在系统 WebView 中,通过 Tauri 定义的安全接口与后端的 Rust 逻辑通信。Rust 处理所有“重”活,并确保应用稳定、高效。
3. 开发环境搭建与项目初始化
3.1 前置环境准备
开始之前,需要确保你的 Windows 开发环境已经就绪。
Rust 工具链:这是核心。访问 rustup.rs 下载并运行安装脚本。安装过程中,选择默认的
stable版本和x86_64-pc-windows-msvc工具链即可。安装完成后,在终端运行rustc --version和cargo --version验证。注意:Rust 安装可能会因为网络问题较慢,可以考虑设置国内镜像源。在用户目录下的
.cargo文件夹中创建config文件,加入以下内容可以显著加速依赖下载:[source.crates-io] replace-with = 'ustc' [source.ustc] registry = "git://mirrors.ustc.edu.cn/crates.io-index"Node.js 与 pnpm:前端部分需要 Node.js。建议安装 LTS 版本。包管理器我推荐
pnpm,它速度更快,磁盘空间利用更高效。安装 Node.js 后,可以通过npm install -g pnpm来安装 pnpm。WebView2 运行时:Tauri 依赖微软的 WebView2。Windows 10 和 11 的较新版本通常已预装。如果没有,Tauri 应用在首次运行时也会引导用户安装,但为了开发方便,建议手动安装一次。可以从微软官网下载“Evergreen Standalone Installer”进行安装。
3.2 使用 Tauri CLI 快速创建项目
Tauri 提供了非常方便的命令行工具来搭建项目骨架。
- 打开终端(如 PowerShell),进入你的工作目录。
- 运行以下命令来创建项目:
pnpm create tauri-app - 命令行会交互式地询问配置:
- 项目名称:输入
pomodoro-timer(或其他你喜欢的名字)。 - 窗口标题:同样可以输入
Pomodoro Timer。 - 前端框架:选择
React。 - 包管理器:选择
pnpm。 - UI 模板:选择
TypeScript以获得更好的类型提示。 - 特性:保持默认即可。
- 项目名称:输入
这个过程会创建一个包含完整前后端结构的项目。前端部分在src目录下,是一个标准的 Vite + React + TS 项目。后端 Rust 代码在src-tauri目录下。
3.3 项目结构初探与必要依赖
创建完成后,先浏览一下关键目录:
pomodoro-timer/ ├── src/ # 前端 React 源代码 │ ├── main.tsx # 应用入口 │ └── App.tsx # 主组件 ├── src-tauri/ # 后端 Rust 源代码 │ ├── Cargo.toml # Rust 项目配置和依赖 │ ├── src/ │ │ └── main.rs # Rust 程序入口 │ └── tauri.conf.json # Tauri 应用配置文件 ├── index.html # 前端 HTML 入口 └── vite.config.ts # Vite 配置进入项目根目录,首先安装前端依赖并启动开发服务器:
cd pomodoro-timer pnpm install pnpm tauri devtauri dev命令会同时启动 Vite 开发服务器和编译 Rust 后端,并打开一个应用窗口。你会看到一个默认的 Tauri 应用窗口,这意味着环境搭建成功。
接下来,添加一些我们需要的 UI 组件库。这里使用shadcn/ui,它是一组基于 Radix UI 的高质量、可定制的组件。
pnpm dlx shadcn@latest init按照提示初始化,选择使用tailwind.config.ts和src/components.json。然后添加我们需要的按钮、卡片、进度条等组件:
pnpm dlx shadcn@latest add button card progress同时,安装一个图标库,比如lucide-react,它提供了丰富的图标。
pnpm add lucide-react现在,开发环境已经准备就绪,我们可以开始构思和实现番茄钟的核心功能了。
4. 核心功能设计与 Rust 后端实现
4.1 状态机模型:定义番茄钟的生命周期
一个番茄钟的核心逻辑其实是一个状态机。通常,一个完整的番茄周期包含“专注工作时间”和“短休息时间”,若干个周期后有一个“长休息时间”。我们需要在 Rust 后端清晰地定义这些状态。
在src-tauri/src目录下,我们可以创建一个timer.rs文件来管理计时器逻辑。首先定义状态枚举和核心数据结构:
// src-tauri/src/timer.rs use serde::{Deserialize, Serialize}; use tauri::State; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum TimerState { Idle, // 空闲 Running, // 运行中 (专注或休息) Paused, // 已暂停 } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum TimerMode { Focus, // 专注模式 ShortBreak, // 短休息 LongBreak, // 长休息 } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TimerSession { pub mode: TimerMode, pub total_seconds: u64, // 该模式总时长(秒) pub remaining_seconds: u64, // 剩余秒数 pub state: TimerState, pub start_time: Option<Instant>, // 开始运行的时刻 } pub struct PomodoroTimer { pub session: Arc<Mutex<TimerSession>>, // 可以添加更多字段,如已完成番茄数、配置等 } impl PomodoroTimer { pub fn new(focus_minutes: u64, short_break_minutes: u64, long_break_minutes: u64) -> Self { let session = TimerSession { mode: TimerMode::Focus, total_seconds: focus_minutes * 60, remaining_seconds: focus_minutes * 60, state: TimerState::Idle, start_time: None, }; PomodoroTimer { session: Arc::new(Mutex::new(session)), } } pub fn start(&self) -> Result<(), String> { let mut session = self.session.lock().unwrap(); if session.state == TimerState::Idle || session.state == TimerState::Paused { session.state = TimerState::Running; session.start_time = Some(Instant::now()); Ok(()) } else { Err("Timer is already running".to_string()) } } pub fn pause(&self) -> Result<(), String> { let mut session = self.session.lock().unwrap(); if session.state == TimerState::Running { // 计算从开始到现在过去了多久,更新剩余时间 if let Some(start) = session.start_time { let elapsed = start.elapsed().as_secs(); if elapsed < session.remaining_seconds { session.remaining_seconds -= elapsed; } else { session.remaining_seconds = 0; } } session.state = TimerState::Paused; session.start_time = None; Ok(()) } else { Err("Timer is not running".to_string()) } } pub fn reset(&self, focus_minutes: u64) -> Result<(), String> { let mut session = self.session.lock().unwrap(); session.mode = TimerMode::Focus; session.total_seconds = focus_minutes * 60; session.remaining_seconds = focus_minutes * 60; session.state = TimerState::Idle; session.start_time = None; Ok(()) } // 获取当前状态和剩余时间(供前端轮询或事件触发) pub fn get_status(&self) -> TimerSession { let session = self.session.lock().unwrap(); let mut session_clone = session.clone(); // 如果正在运行,实时计算剩余时间 if session_clone.state == TimerState::Running && session_clone.start_time.is_some() { let elapsed = session_clone.start_time.unwrap().elapsed().as_secs(); if elapsed < session_clone.remaining_seconds { session_clone.remaining_seconds -= elapsed; } else { session_clone.remaining_seconds = 0; // 理论上这里应该触发计时结束的逻辑,我们稍后处理 } } session_clone } }这段代码定义了番茄钟的核心数据模型和基本操作。Arc<Mutex<TimerSession>>是一个常见的线程安全共享数据模式,允许在 Tauri 的命令(Command)中安全地访问和修改计时器状态。
4.2 Tauri 命令(Commands):暴露后端 API 给前端
前端不能直接调用 Rust 结构体的方法,需要通过 Tauri 的“命令”系统。命令是 Rust 后端暴露给前端的函数。我们在src-tauri/src/main.rs中注册这些命令。
首先,在main.rs中引入我们的timer模块,并在tauri::Builder中管理状态:
// src-tauri/src/main.rs mod timer; use timer::PomodoroTimer; #[tauri::command] fn start_timer(timer_state: tauri::State<PomodoroTimer>) -> Result<(), String> { timer_state.start() } #[tauri::command] fn pause_timer(timer_state: tauri::State<PomodoroTimer>) -> Result<(), String> { timer_state.pause() } #[tauri::command] fn reset_timer(timer_state: tauri::State<PomodoroTimer>, focus_minutes: u64) -> Result<(), String> { timer_state.reset(focus_minutes) } #[tauri::command] fn get_timer_status(timer_state: tauri::State<PomodoroTimer>) -> TimerSession { timer_state.get_status() } fn main() { tauri::Builder::default() .manage(PomodoroTimer::new(25, 5, 15)) // 默认25分钟专注,5分钟短休,15分钟长休 .invoke_handler(tauri::generate_handler![ start_timer, pause_timer, reset_timer, get_timer_status ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); }现在,前端 JavaScript/TypeScript 代码就可以通过invoke函数调用这些命令,例如await invoke('start_timer')。
4.3 计时器驱动与事件通知
上面的get_status需要前端不断轮询来更新界面,这不是最佳实践。更好的方式是让 Rust 后端在计时结束时主动通知前端。我们可以利用 Tauri 的事件系统。
我们需要一个后台任务来驱动计时器。这可以通过std::thread::spawn创建一个守护线程。修改PomodoroTimer结构,加入一个发送事件的句柄:
// 在 timer.rs 中 use tauri::{AppHandle, Emitter}; impl PomodoroTimer { pub fn run_background_task(&self, app_handle: AppHandle) { let session = Arc::clone(&self.session); std::thread::spawn(move || { loop { std::thread::sleep(Duration::from_secs(1)); // 每秒检查一次 let mut session_guard = session.lock().unwrap(); if session_guard.state == TimerState::Running && session_guard.start_time.is_some() { let elapsed = session_guard.start_time.unwrap().elapsed().as_secs(); if elapsed >= session_guard.remaining_seconds { // 时间到! session_guard.remaining_seconds = 0; session_guard.state = TimerState::Idle; session_guard.start_time = None; // 释放锁,避免在持有锁时发送事件(可能导致死锁) drop(session_guard); // 发送事件到前端 let _ = app_handle.emit("timer_finished", ()); // 这里还可以触发系统通知 let _ = app_handle.emit("show_notification", "时间到!该休息了~"); } } } }); } }然后在main.rs的setup钩子中启动这个后台任务:
// main.rs #[tauri::command] // ... 其他命令 fn main() { tauri::Builder::default() .manage(PomodoroTimer::new(25, 5, 15)) .invoke_handler(tauri::generate_handler![/* commands */]) .setup(|app| { let app_handle = app.handle(); let timer_state: tauri::State<PomodoroTimer> = app.state(); timer_state.run_background_task(app_handle.clone()); Ok(()) }) .run(tauri::generate_context!()) .expect("error while running tauri application"); }这样,一个由 Rust 后端驱动的、精确的、并能主动通知前端的计时器核心就完成了。
5. React 前端界面与状态联动
5.1 构建主界面组件
前端的工作是提供一个美观且交互流畅的界面。我们在src/App.tsx中构建主界面。首先,定义好与后端通信的接口。
// src/App.tsx import { useEffect, useState } from 'react'; import { invoke } from '@tauri-apps/api/tauri'; import { listen } from '@tauri-apps/api/event'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Progress } from '@/components/ui/progress'; import { Play, Pause, RotateCcw, Settings } from 'lucide-react'; interface TimerSession { mode: 'Focus' | 'ShortBreak' | 'LongBreak'; total_seconds: number; remaining_seconds: number; state: 'Idle' | 'Running' | 'Paused'; } function App() { const [session, setSession] = useState<TimerSession | null>(null); const [focusDuration, setFocusDuration] = useState(25); // 可配置 // 获取初始状态 useEffect(() => { fetchTimerStatus(); }, []); // 监听后端发来的计时结束事件 useEffect(() => { const unlisten = listen('timer_finished', () => { alert('时间到!'); fetchTimerStatus(); // 重新获取状态 }); return () => { unlisten.then(f => f()); }; }, []); const fetchTimerStatus = async () => { try { const status: TimerSession = await invoke('get_timer_status'); setSession(status); } catch (error) { console.error('Failed to fetch timer status:', error); } }; const handleStart = async () => { await invoke('start_timer'); fetchTimerStatus(); }; const handlePause = async () => { await invoke('pause_timer'); fetchTimerStatus(); }; const handleReset = async () => { await invoke('reset_timer', { focusMinutes: focusDuration }); fetchTimerStatus(); }; if (!session) return <div>Loading...</div>; const progressPercentage = session.total_seconds > 0 ? ((session.total_seconds - session.remaining_seconds) / session.total_seconds) * 100 : 0; const formatTime = (seconds: number) => { const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; }; return ( <div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 p-8"> <Card className="w-full max-w-md mx-auto"> <CardHeader> <CardTitle className="text-3xl font-bold text-center"> {session.mode === 'Focus' ? '🍅 专注时间' : '☕️ 休息时间'} </CardTitle> </CardHeader> <CardContent className="space-y-8"> {/* 时间显示 */} <div className="text-center"> <div className="text-7xl font-mono font-bold text-gray-800"> {formatTime(session.remaining_seconds)} </div> <Progress value={progressPercentage} className="mt-4 h-2" /> </div> {/* 控制按钮 */} <div className="flex justify-center space-x-4"> {session.state === 'Running' ? ( <Button size="lg" onClick={handlePause}> <Pause className="mr-2 h-5 w-5" /> 暂停 </Button> ) : ( <Button size="lg" onClick={handleStart}> <Play className="mr-2 h-5 w-5" /> 开始 </Button> )} <Button size="lg" variant="outline" onClick={handleReset}> <RotateCcw className="mr-2 h-5 w-5" /> 重置 </Button> <Button size="icon" variant="ghost"> <Settings className="h-5 w-5" /> </Button> </div> {/* 状态显示 */} <div className="text-center text-sm text-gray-500"> 状态: {session.state === 'Idle' ? '就绪' : session.state === 'Running' ? '进行中' : '已暂停'} <br /> 模式: {session.mode} </div> </CardContent> </Card> </div> ); } export default App;这个组件创建了一个简洁的番茄钟界面,显示剩余时间、进度条和控制按钮,并通过 Tauri 的invoke与 Rust 后端交互。
5.2 状态同步与性能优化
上面的例子中,我们通过手动调用fetchTimerStatus来更新状态。为了获得更流畅的体验,我们可以使用定时器或更优雅地监听后端状态变化。
一个简单的优化是,当计时器处于Running状态时,在前端也启动一个setInterval来更频繁地更新显示(比如每秒一次),而不是等待后端事件。但要注意与后端事件去重。
// 在 App.tsx 的 useEffect 中 useEffect(() => { let intervalId: NodeJS.Timeout; if (session?.state === 'Running') { intervalId = setInterval(() => { // 对于运行中的计时器,我们乐观地在前端递减时间,同时定期从后端同步真实状态 setSession(prev => { if (!prev || prev.state !== 'Running' || prev.remaining_seconds <= 0) return prev; return { ...prev, remaining_seconds: prev.remaining_seconds - 1 }; }); }, 1000); } return () => { if (intervalId) clearInterval(intervalId); }; }, [session?.state]);同时,可以保留一个稍长间隔(比如每10秒)从后端invoke('get_timer_status')同步一次,以校正前端可能累积的误差。这种“前端乐观更新 + 后端定期同步”的策略,能在保证界面流畅的同时维持状态的准确性。
5.3 系统托盘与窗口控制
一个真正的桌面番茄钟应该支持最小化到系统托盘,以及通过托盘菜单进行控制。Tauri 对此有很好的支持。
首先,在src-tauri/tauri.conf.json中启用系统托盘功能:
{ "tauri": { "allowlist": { "tray": { "all": true } }, "bundle": {}, "build": {}, "systemTray": { "iconPath": "icons/icon.ico" } } }然后,在 Rust 后端 (main.rs) 中创建托盘和菜单:
use tauri::{CustomMenuItem, SystemTray, SystemTrayMenu, SystemTrayMenuItem}; fn main() { let show = CustomMenuItem::new("show".to_string(), "显示窗口"); let quit = CustomMenuItem::new("quit".to_string(), "退出"); let tray_menu = SystemTrayMenu::new() .add_item(show) .add_native_item(SystemTrayMenuItem::Separator) .add_item(quit); let system_tray = SystemTray::new().with_menu(tray_menu); tauri::Builder::default() .system_tray(system_tray) .on_system_tray_event(|app, event| match event { tauri::SystemTrayEvent::MenuItemClick { id, .. } => match id.as_str() { "show" => { let window = app.get_window("main").unwrap(); window.show().unwrap(); window.set_focus().unwrap(); } "quit" => { app.exit(0); } _ => {} }, _ => {} }) // ... 其他配置 .run(tauri::generate_context!()) .expect("error while running tauri application"); }这样,应用启动后会在系统托盘区显示一个图标。点击图标可以显示菜单,选择“显示窗口”或“退出”。你还可以为不同的计时状态(如运行、暂停)设置不同的托盘图标,提供更直观的反馈。
6. 数据持久化与配置管理
6.1 使用 SQLite 存储任务历史
番茄钟不仅用于计时,记录完成的任务和历史数据也很有价值。我们可以使用sqlx库和 SQLite 数据库来持久化数据。
首先,在src-tauri/Cargo.toml中添加依赖:
[dependencies] sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite"] } tokio = { version = "1", features = ["full"] }在 Rust 后端初始化数据库连接池,并定义数据模型和操作函数:
// src-tauri/src/db.rs use sqlx::{sqlite::SqlitePoolOptions, SqlitePool}; use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize)] pub struct TaskRecord { pub id: i64, pub start_time: i64, // 时间戳 pub duration_seconds: i64, pub mode: String, // “Focus”, “ShortBreak”, “LongBreak” pub task_description: Option<String>, } pub struct Database { pool: SqlitePool, } impl Database { pub async fn new(db_path: &str) -> Result<Self, sqlx::Error> { let pool = SqlitePoolOptions::new() .connect(&format!("sqlite:{}", db_path)) .await?; // 创建表 sqlx::query( r#" CREATE TABLE IF NOT EXISTS task_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, start_time INTEGER NOT NULL, duration_seconds INTEGER NOT NULL, mode TEXT NOT NULL, task_description TEXT ) "#, ) .execute(&pool) .await?; Ok(Database { pool }) } pub async fn insert_record(&self, record: &TaskRecord) -> Result<i64, sqlx::Error> { let result = sqlx::query( r#" INSERT INTO task_records (start_time, duration_seconds, mode, task_description) VALUES (?, ?, ?, ?) "#, ) .bind(record.start_time) .bind(record.duration_seconds) .bind(&record.mode) .bind(&record.task_description) .execute(&self.pool) .await?; Ok(result.last_insert_rowid()) } pub async fn get_today_focus_time(&self) -> Result<i64, sqlx::Error> { let start_of_day = chrono::Local::now().date_naive().and_hms_opt(0, 0, 0).unwrap().timestamp(); let row: (i64,) = sqlx::query_as( r#" SELECT COALESCE(SUM(duration_seconds), 0) FROM task_records WHERE mode = 'Focus' AND start_time >= ? "#, ) .bind(start_of_day) .fetch_one(&self.pool) .await?; Ok(row.0) } }然后,在计时器完成一个会话时,调用insert_record方法保存记录。同时,可以暴露新的 Tauri 命令,让前端查询今日专注总时长等统计数据。
6.2 应用配置的读取与保存
用户可能希望自定义专注时长、休息时长、提示音等。这些配置可以保存在一个 JSON 文件中,使用serde_json进行读写。
// src-tauri/src/config.rs use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; use tauri::api::path::app_config_dir; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct AppConfig { pub focus_minutes: u64, pub short_break_minutes: u64, pub long_break_minutes: u64, pub long_break_interval: u64, // 几个番茄后长休息 pub enable_sound: bool, pub sound_path: Option<String>, } impl Default for AppConfig { fn default() -> Self { Self { focus_minutes: 25, short_break_minutes: 5, long_break_minutes: 15, long_break_interval: 4, enable_sound: true, sound_path: None, } } } impl AppConfig { pub fn load() -> Result<Self, Box<dyn std::error::Error>> { let config_dir = app_config_dir(&tauri::generate_context!().config()).unwrap(); let config_path = config_dir.join("config.json"); if config_path.exists() { let content = fs::read_to_string(config_path)?; Ok(serde_json::from_str(&content)?) } else { let config = Self::default(); config.save()?; Ok(config) } } pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> { let config_dir = app_config_dir(&tauri::generate_context!().config()).unwrap(); fs::create_dir_all(&config_dir)?; let config_path = config_dir.join("config.json"); let content = serde_json::to_string_pretty(self)?; fs::write(config_path, content)?; Ok(()) } }在main.rs中,可以将AppConfig也纳入状态管理,并提供get_config和update_config命令供前端调用。这样,一个带有数据持久化和用户配置的番茄钟应用就更完整了。
7. 构建、打包与分发
7.1 调试与问题排查
在开发过程中,你可能会遇到各种问题。这里记录几个常见坑点:
- 前端热更新失效:检查
tauri.conf.json中的devPath是否指向正确的 Vite 开发服务器地址(通常是http://localhost:1420)。确保没有防火墙或杀毒软件阻止连接。 - Rust 编译错误:仔细阅读错误信息。常见问题包括依赖版本冲突、未导入的模块、所有权错误等。使用
cargo check进行快速语法检查。 - 命令调用失败:确保前端
invoke的命令名与 Rust 后端#[tauri::command]修饰的函数名完全一致,且参数类型匹配。使用console.log或println!调试。 - 系统托盘图标不显示:确认图标文件路径正确,且格式被支持(如
.ico对于 Windows)。图标文件需要放在src-tauri目录下,并在配置中正确引用。
7.2 生产环境构建
开发完成后,使用以下命令构建发布版本的应用:
pnpm tauri build这个过程会:
- 构建优化的前端代码(Vite 生产模式构建)。
- 编译优化的 Rust 二进制文件。
- 收集所有资源(图标、配置文件等)。
- 根据
tauri.conf.json的配置,生成安装包。
对于 Windows,默认会生成一个.msi安装包,位于src-tauri/target/release/bundle/msi/目录下。这个安装包体积非常小,通常只有几 MB,这正是 Tauri 的优势。
7.3 应用签名与分发
如果你想公开发布应用,应用签名是必须的。未签名的应用在 Windows 上运行时,会弹出“Windows 已保护你的电脑”的警告,非常影响用户体验。
- 获取代码签名证书:你需要从受信任的证书颁发机构(如 DigiCert, Sectigo)购买一个代码签名证书,或者使用开源项目适用的免费证书(如 OV 证书申请流程复杂,且通常不免费)。
- 配置 Tauri 签名:在
tauri.conf.json中配置签名:{ "tauri": { "bundle": { "windows": { "certificateThumbprint": "YOUR_CERTIFICATE_THUMBPRINT", "digestAlgorithm": "sha256", "timestampUrl": "http://timestamp.digicert.com" } } } } - 构建签名应用:设置好环境变量(指向你的
.pfx文件和密码),再次运行pnpm tauri build,生成的安装包就是已签名的。
对于个人项目或内部使用,如果不方便购买证书,可以明确告知用户需要手动点击“更多信息”->“仍要运行”。但为了最佳体验,签名是推荐步骤。
至此,一个功能完整、体验接近原生、体积轻巧的 Windows 本地番茄钟应用就从零开始构建完成了。从技术选型、环境搭建、核心逻辑实现、前后端通信、到状态管理、数据持久化,最后打包分发,我们走完了桌面应用开发的完整流程。这套 Tauri + React + Rust 的组合,在性能、体验和开发效率之间取得了很好的平衡,非常适合开发此类中小型桌面工具。你可以基于这个基础,继续添加更多功能,比如任务列表、统计图表、云端同步(需注意隐私)等,打造一个完全属于自己的生产力利器。