Tauri:打造跨平台系统监控工具

用 Tauri 做了一个系统监控小工具,能实时查看 CPU、内存、磁盘和网络状态,支持系统托盘和开机自启。完整走一遍从搭建到打包的过程。

为什么选 Tauri

之前用 Electron 写过类似的工具,打包出来 150MB+,内存占用 200MB 起步。对于一个系统监控工具来说,自己就是资源大户,多少有点讽刺。Tauri 用系统 WebView 渲染前端,后端是 Rust,打包体积 5~8MB,内存占用 30MB 左右,适合这种常驻后台的工具。

项目初始化

# 安装 Tauri CLI
cargo install create-tauri-app
# 创建项目,前端选 vanilla(不用框架,够简单)
cargo create-tauri-app sys-monitor --template vanilla
cd sys-monitor

Cargo.toml 中添加系统信息采集的依赖:

[dependencies]
tauri = { version = "1.5", features = ["system-tray", "shell-open"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sysinfo = "0.30"

后端:采集系统信息

核心是用 sysinfo crate 采集各项指标。定义一个数据结构,通过 Tauri 的 command 机制暴露给前端:

use serde::Serialize;
use sysinfo::{CpuExt, DiskExt, NetworkExt, NetworksExt, System, SystemExt};
use std::sync::Mutex;
use tauri::State;

#[derive(Serialize, Clone)]
pub struct SystemInfo {
    cpu_usage: Vec<f32>,          // 每个核心的使用率
    cpu_avg: f32,                 // 平均 CPU 使用率
    memory_total: u64,            // 总内存 (bytes)
    memory_used: u64,             // 已用内存
    memory_usage: f32,            // 内存使用率
    disks: Vec<DiskInfo>,
    network_rx: u64,              // 接收字节数
    network_tx: u64,              // 发送字节数
    uptime: u64,                  // 系统运行时间(秒)
    hostname: String,
}

#[derive(Serialize, Clone)]
pub struct DiskInfo {
    name: String,
    total: u64,
    available: u64,
    usage: f32,
}

pub struct SysState(pub Mutex<System>);

#[tauri::command]
fn get_system_info(state: State<SysState>) -> SystemInfo {
    let mut sys = state.0.lock().unwrap();
    sys.refresh_all();

    let cpu_usage: Vec<f32> = sys.cpus().iter().map(|c| c.cpu_usage()).collect();
    let cpu_avg = cpu_usage.iter().sum::<f32>() / cpu_usage.len() as f32;

    let disks: Vec<DiskInfo> = sys.disks().iter().map(|d| {
        let total = d.total_space();
        let available = d.available_space();
        DiskInfo {
            name: d.name().to_string_lossy().to_string(),
            total,
            available,
            usage: (total - available) as f32 / total as f32 * 100.0,
        }
    }).collect();

    let (rx, tx) = sys.networks().iter().fold((0u64, 0u64), |(rx, tx), (_, data)| {
        (rx + data.received(), tx + data.transmitted())
    });

    SystemInfo {
        cpu_usage,
        cpu_avg,
        memory_total: sys.total_memory(),
        memory_used: sys.used_memory(),
        memory_usage: sys.used_memory() as f32 / sys.total_memory() as f32 * 100.0,
        disks,
        network_rx: rx,
        network_tx: tx,
        uptime: sys.uptime(),
        hostname: sys.host_name().unwrap_or_default(),
    }
}

main.rs 中注册:

fn main() {
    let sys = SysState(Mutex::new(System::new_all()));
    tauri::Builder::default()
        .manage(sys)
        .invoke_handler(tauri::generate_handler![get_system_info])
        .system_tray(build_tray())
        .on_system_tray_event(handle_tray_event)
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

前端:实时图表

前端用 Chart.js 做实时折线图。通过 setInterval 每秒调用一次后端 command:

<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<div class="dashboard">
  <div class="card">
    <h3>CPU 使用率</h3>
    <canvas id="cpuChart"></canvas>
    <span id="cpuValue" class="value">0%</span>
  </div>
  <div class="card">
    <h3>内存使用率</h3>
    <canvas id="memChart"></canvas>
    <span id="memValue" class="value">0 / 0 GB</span>
  </div>
  <div class="card">
    <h3>磁盘空间</h3>
    <div id="diskBars"></div>
  </div>
  <div class="card">
    <h3>网络流量</h3>
    <canvas id="netChart"></canvas>
  </div>
</div>
const { invoke } = window.__TAURI__.tauri;

const MAX_POINTS = 60; // 保留最近 60 秒数据
const cpuData = { labels: [], data: [] };
const memData = { labels: [], data: [] };
const netData = { labels: [], rx: [], tx: [] };

let prevRx = 0, prevTx = 0;

// 初始化 CPU 图表
const cpuChart = new Chart(document.getElementById('cpuChart'), {
    type: 'line',
    data: {
        labels: cpuData.labels,
        datasets: [{
            label: 'CPU %',
            data: cpuData.data,
            borderColor: '#4fc3f7',
            backgroundColor: 'rgba(79, 195, 247, 0.1)',
            fill: true,
            tension: 0.3,
            pointRadius: 0,
        }]
    },
    options: {
        responsive: true,
        scales: { y: { min: 0, max: 100 } },
        animation: { duration: 0 },
        plugins: { legend: { display: false } }
    }
});

// 内存和网络图表类似初始化...

async function updateStats() {
    const info = await invoke('get_system_info');
    const now = new Date().toLocaleTimeString();

    // CPU
    cpuData.labels.push(now);
    cpuData.data.push(info.cpu_avg.toFixed(1));
    if (cpuData.labels.length > MAX_POINTS) {
        cpuData.labels.shift();
        cpuData.data.shift();
    }
    cpuChart.update();
    document.getElementById('cpuValue').textContent = `${info.cpu_avg.toFixed(1)}%`;

    // 内存
    const memGB = (info.memory_used / 1024 / 1024 / 1024).toFixed(1);
    const totalGB = (info.memory_total / 1024 / 1024 / 1024).toFixed(1);
    document.getElementById('memValue').textContent = `${memGB} / ${totalGB} GB`;

    // 网络速率(差值计算)
    const rxSpeed = prevRx ? (info.network_rx - prevRx) : 0;
    const txSpeed = prevTx ? (info.network_tx - prevTx) : 0;
    prevRx = info.network_rx;
    prevTx = info.network_tx;

    // 磁盘条形图
    const diskBars = document.getElementById('diskBars');
    diskBars.innerHTML = info.disks.map(d => `
        <div class="disk-item">
            <span>${d.name || 'Disk'}</span>
            <div class="bar"><div class="fill" style="width:${d.usage.toFixed(0)}%"></div></div>
            <span>${d.usage.toFixed(0)}%</span>
        </div>
    `).join('');
}

setInterval(updateStats, 1000);
updateStats();

系统托盘

Tauri 原生支持系统托盘,关闭窗口时最小化到托盘而不是退出:

use tauri::{CustomMenuItem, SystemTray, SystemTrayMenu, SystemTrayEvent, Manager};

fn build_tray() -> SystemTray {
    let menu = SystemTrayMenu::new()
        .add_item(CustomMenuItem::new("show", "显示窗口"))
        .add_item(CustomMenuItem::new("quit", "退出"));
    SystemTray::new().with_menu(menu)
}

fn handle_tray_event(app: &tauri::AppHandle, event: SystemTrayEvent) {
    match event {
        SystemTrayEvent::DoubleClick { .. } => {
            let window = app.get_window("main").unwrap();
            window.show().unwrap();
            window.set_focus().unwrap();
        }
        SystemTrayEvent::MenuItemClick { id, .. } => match id.as_str() {
            "show" => {
                let window = app.get_window("main").unwrap();
                window.show().unwrap();
                window.set_focus().unwrap();
            }
            "quit" => std::process::exit(0),
            _ => {}
        },
        _ => {}
    }
}

窗口关闭时拦截,改为隐藏:

// 在 Builder 链中添加
.on_window_event(|event| {
    if let tauri::WindowEvent::CloseRequested { api, .. } = event.event() {
        event.window().hide().unwrap();
        api.prevent_close();
    }
})

开机自启动

Windows 下通过注册表实现,Linux 下通过 .desktop 文件。Tauri 社区有 tauri-plugin-autostart

# Cargo.toml
[dependencies]
tauri-plugin-autostart = "0.1"
use tauri_plugin_autostart::MacosLauncher;

fn main() {
    tauri::Builder::default()
        .plugin(tauri_plugin_autostart::init(
            MacosLauncher::LaunchAgent,
            Some(vec![]),
        ))
        // ... 其他配置
}

前端提供一个开关让用户控制:

const { isEnabled, enable, disable } = window.__TAURI_PLUGIN_AUTOSTART__;

async function toggleAutostart(checked) {
    if (checked) {
        await enable();
    } else {
        await disable();
    }
}

打包发布

Tauri 打包非常简单:

# 开发
cargo tauri dev

# 打包 release
cargo tauri build

Windows 下生成 .msi.exe 安装包,Linux 下生成 .deb.AppImage,macOS 下生成 .dmg

最终效果:

  • Windows 安装包:6.2 MB
  • 运行内存占用:约 28 MB
  • CPU 开销:<0.5%(1 秒刷新间隔)

对比之前 Electron 版本的 150MB 安装包和 200MB 内存,差距巨大。Tauri 的缺点是 Rust 编译慢(全量编译 2~3 分钟)、WebView 在不同平台的渲染一致性不如 Chromium。但对于这种工具类应用,完全可以接受。