用 Tauri 做一个本地的 Markdown 笔记应用

Tauri 的价值在「体积」——同一个应用,Electron 打出来 100MB+,Tauri 通常 5-10MB。这篇用一个本地 Markdown 笔记应用把 Tauri 的核心能力串起来:文件读写、IPC 命令、窗口状态、打包分发。

一、为什么这个选题适合练 Tauri

Markdown 笔记应用正好命中 Tauri 的三个核心场景:

  • 需要读写本地文件——Web 做不到,必须走 Rust 侧
  • 需要长期驻留——桌面应用形态,不是网页
  • 需要小巧——用户不愿为一个笔记工具装 100MB

而且业务逻辑简单(读文件、写文件、列目录),可以把注意力放在 Tauri 的机制上。

二、脚手架与目录

npm create tauri-app@latest note-app
1. 选择:TypeScript + React + Vite

cd note-app
npm run tauri dev      # 开发(热重载)

1. 目录结构
src/                   # 前端(React)
src-tauri/
├── src/lib.rs         # Rust 逻辑,命令写在这里
├── Cargo.toml
├── tauri.conf.json    # 应用配置
└── capabilities/
    └── default.json   # 权限声明(v2 的核心)

三、Rust 侧:三个命令

笔记应用只需要三个能力:列出笔记、读笔记、写笔记。

// src-tauri/src/lib.rs
use std::fs;
use std::path::PathBuf;
use tauri::Manager;

// 笔记目录:放在应用数据目录下,跨平台自动适配
fn notes_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
    let dir = app.path().app_data_dir()
        .map_err(|e| e.to_string())?
        .join("notes");
    fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
    Ok(dir)
}

#[derive(serde::Serialize)]
struct NoteMeta {
    name: String,
    modified: u64,
}

// 1. 列出所有笔记(按修改时间倒序)
#[tauri::command]
fn list_notes(app: tauri::AppHandle) -> Result<Vec<NoteMeta>, String> {
    let dir = notes_dir(&app)?;
    let mut out = Vec::new();
    for entry in fs::read_dir(&dir).map_err(|e| e.to_string())? {
        let e = entry.map_err(|e| e.to_string())?;
        let path = e.path();
        if path.extension().and_then(|s| s.to_str()) != Some("md") {
            continue;
        }
        let modified = e.metadata().ok()
            .and_then(|m| m.modified().ok())
            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
            .map(|d| d.as_secs()).unwrap_or(0);
        out.push(NoteMeta {
            name: path.file_stem().unwrap().to_string_lossy().into_owned(),
            modified,
        });
    }
    out.sort_by(|a, b| b.modified.cmp(&a.modified));
    Ok(out)
}

// 2. 读笔记
#[tauri::command]
fn read_note(app: tauri::AppHandle, name: String) -> Result<String, String> {
    let path = notes_dir(&app)?.join(format!("{name}.md"));
    fs::read_to_string(path).map_err(|e| e.to_string())
}

// 3. 写笔记
#[tauri::command]
fn write_note(app: tauri::AppHandle, name: String, content: String) -> Result<(), String> {
    let path = notes_dir(&app)?.join(format!("{name}.md"));
    fs::write(path, content).map_err(|e| e.to_string())
}

pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_dialog::init())
        .invoke_handler(tauri::generate_handler![list_notes, read_note, write_note])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
  • app_data_dir() 是跨平台的:Linux 在 ~/.local/share/,macOS 在 ~/Library/Application Support/,Windows 在 %APPDATA%,不用自己判断
  • 文件名要防路径穿越:上面代码里 name 直接拼路径,如果前端传 ../../etc/passwd 就危险了——生产必须校验(见下文「安全」一节)
  • 返回 Result<T, String> 而非 panic:错误要能传到前端展示

四、配置与权限(v2 与 v1 最大的区别)

// src-tauri/tauri.conf.json
{
  "$schema": "https://schema.tauri.app/config/2",
  "productName": "note-app",
  "identifier": "com.example.noteapp",
  "build": {
    "frontendDist": "../dist",
    "devUrl": "http://localhost:5173",
    "beforeDevCommand": "npm run dev",
    "beforeBuildCommand": "npm run build"
  },
  "app": {
    "windows": [
      { "title": "笔记", "width": 900, "height": 640, "minWidth": 500 }
    ]
  },
  "bundle": { "active": true, "targets": ["dmg", "msi", "appimage"] }
}
// src-tauri/capabilities/default.json
{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "dialog:default"     // 用到 dialog 插件就声明
  ]
}

v2 的最大变化是权限模型:默认最小权限,每个插件的能力都要显式声明。报「not allowed」时先查 capabilities,不要先怀疑代码——这是 v2 迁移最常见的卡点。

五、前端调用

// src/App.tsx
import { invoke } from '@tauri-apps/api/core'
import { useEffect, useState } from 'react'

type NoteMeta = { name: string; modified: number }

export default function App() {
  const [notes, setNotes] = useState<NoteMeta[]>([])
  const [current, setCurrent] = useState('')
  const [content, setContent] = useState('')

  const refresh = () => invoke<NoteMeta[]>('list_notes').then(setNotes)

  useEffect(() => { refresh() }, [])

  const open = async (name: string) => {
    setCurrent(name)
    setContent(await invoke<string>('read_note', { name }))
  }

  const save = async () => {
    if (!current) return
    await invoke('write_note', { name: current, content })
    refresh()      // 保存后刷新列表(修改时间变了)
  }

  return (
    <div className="app">
      <aside>
        {notes.map(n => (
          <button key={n.name} onClick={() => open(n.name)}>{n.name}</button>
        ))}
      </aside>
      <main>
        <input value={current} onChange={e => setCurrent(e.target.value)} placeholder="文件名" />
        <textarea value={content} onChange={e => setContent(e.target.value)} />
        <button onClick={save}>保存</button>
      </main>
    </div>
  )
}

参数名要注意大小写:Rust 的 snake_case 参数(如 note_name)在前端调用时用 camelCase(noteName)。

六、安全:路径穿越必须防

上面 write_note 直接把 name 拼进路径。如果前端被注入或用户手输 ../../../.ssh/authorized_keys,就能写到任意位置。修正:

fn safe_path(app: &tauri::AppHandle, name: &str) -> Result<PathBuf, String> {
    // 1. 只允许字母数字与短横线
    if !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
        return Err("文件名非法".into());
    }
    // 2. 拼出路径后检查是否仍在 notes 目录内
    let dir = notes_dir(app)?;
    let path = dir.join(format!("{name}.md"));
    let canon = path.canonicalize().unwrap_or(path.clone());
    if !canon.starts_with(&dir) {
        return Err("路径越界".into());
    }
    Ok(path)
}
  • 白名单字符比黑名单安全:只允许 a-z0-9-_,比禁止 ../ 更可靠
  • canonicalize 后再比对前缀:防住「看起来在目录内、实际指向外面」的符号链接
  • 这是所有本地文件应用的必修课,不是 Tauri 特有问题

七、打包与体积对比

npm run tauri build
1. 产物在 src-tauri/target/release/bundle/

1. Linux: .deb / .rpm / .AppImage
1. macOS: .dmg / .app
1. Windows: .msi / .exe
方案安装包体积内存占用(空载)说明
Electron(对照)80-120 MB150-250 MB捆绑 Chromium
Tauri(本项目)5-10 MB40-80 MB用系统 WebView

体积差异的代价:Tauri 用系统的 WebView,所以同一个应用在不同系统的渲染效果可能有差异(Linux 的 WebKitGTK 常落后于 Chrome)。纯展示型应用没问题,重前端交互的要先测目标平台。

八、验收清单

检查项怎么验
能列出已有 md 文件在 app_data_dir 里手动放几个 .md,重启应用看是否出现
读写正常编辑保存后,用系统文件管理器打开确认内容已写入
错误能提示故意写只读文件,看前端是否弹出错误
路径穿越被拦前端传 ../test,应报错而不是写入
打包产物能装在另一台机器(或干净虚拟机)装一次
体积符合预期看 bundle 目录里安装包大小

九、总结:Tauri 的三个认知

  • 它是「Web 前端 + Rust 后端」的组合:所有系统能力都在 Rust 侧,通过命令暴露
  • v2 的权限模型是硬约束:不声明就不允许,报错时先查 capabilities
  • 体积优势来自「不捆绑 WebView」,代价是跨平台渲染一致性需要自己验证
  • 本地文件应用必须防路径穿越:这是安全底线,不是可选项
暂无评论

发送评论 编辑评论

|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇