跳至内容
个人工具型桌面应用:Tauri 和 Electron 怎么选

个人工具型桌面应用:Tauri 和 Electron 怎么选

July 15, 2026

做个人 AI 工具时,桌面端常常要:读本地目录、管 API Key、跑后台任务,同时 UI 还想继续用 React/Vue。Electron 和 Tauri 都会进短名单——别只比「谁更新」,要比威胁模型和交付约束

Electron 与 Tauri 架构对比

1. 先看一张决策表

约束更偏向 Electron更偏向 Tauri
团队只会 JS,要两周出第一版
安装包 / 内存是体验一部分
要强一致的 Chromium 渲染
本地密钥、文件权限要默认收紧
重度 Node 原生模块 / 现成生态
长期自用小工具

2. Tauri:把危险能力收进 Rust command

前端不要直接「全能 Node」。Tauri 的健康模式是:UI 只调白名单 command。

2.1 Rust 侧(示意)

// src-tauri/src/lib.rs(Tauri 2 风格示意)
use serde::Serialize;
use std::fs;
use std::path::PathBuf;

#[derive(Serialize)]
struct SafeFile {
    name: String,
    size: u64,
}

#[tauri::command]
fn list_notes_dir(base: String) -> Result<Vec<SafeFile>, String> {
    let root = PathBuf::from(&base);
    // 只允许固定目录,拒绝 .. 逃逸
    let canon = root.canonicalize().map_err(|e| e.to_string())?;
    let allowed = PathBuf::from("/home/user/notes").canonicalize().unwrap();
    if !canon.starts_with(&allowed) {
        return Err("path not allowed".into());
    }

    let mut out = vec![];
    for ent in fs::read_dir(&canon).map_err(|e| e.to_string())? {
        let ent = ent.map_err(|e| e.to_string())?;
        let meta = ent.metadata().map_err(|e| e.to_string())?;
        if meta.is_file() {
            out.push(SafeFile {
                name: ent.file_name().to_string_lossy().into(),
                size: meta.len(),
            });
        }
    }
    Ok(out)
}

#[tauri::command]
fn read_secret_slot(slot: String) -> Result<String, String> {
    // 真实项目:走系统钥匙串 / 加密文件,不要明文写进 localStorage
    if slot != "openai" && slot != "anthropic" {
        return Err("unknown slot".into());
    }
    keyring_get(&slot) // 伪代码
}

2.2 权限配置(关键)

{
  "permissions": [
    "core:default",
    {
      "identifier": "allow-list-notes",
      "commands": {
        "allow": ["list_notes_dir", "read_secret_slot"]
      }
    }
  ]
}

2.3 前端调用

import { invoke } from "@tauri-apps/api/core";

type SafeFile = { name: string; size: number };

export async function loadNotes() {
  const files = await invoke<SafeFile[]>("list_notes_dir", {
    base: "/home/user/notes",
  });
  return files;
}

export async function getApiKey() {
  return invoke<string>("read_secret_slot", { slot: "openai" });
}

实践要点: 文件系统、Shell、任意 HTTP 代理能力,全部走 command + 权限表;前端 XSS 也不等于「整机沦陷」。

3. Electron:用 preload 做桥,别开 nodeIntegration

// preload.js
const { contextBridge, ipcRenderer } = require("electron");

contextBridge.exposeInMainWorld("api", {
  listNotes: (base) => ipcRenderer.invoke("list-notes", base),
  getSecret: (slot) => ipcRenderer.invoke("get-secret", slot),
});
// main.js
const { app, BrowserWindow, ipcMain } = require("electron");
const fs = require("fs");
const path = require("path");

function createWindow() {
  const win = new BrowserWindow({
    webPreferences: {
      preload: path.join(__dirname, "preload.js"),
      contextIsolation: true,
      nodeIntegration: false,
      sandbox: true,
    },
  });
  win.loadFile("index.html");
}

ipcMain.handle("list-notes", async (_e, base) => {
  const allowed = path.resolve("/home/user/notes");
  const target = path.resolve(base);
  if (!target.starts_with(allowed)) throw new Error("path not allowed");
  return fs.readdirSync(target).map((name) => ({
    name,
    size: fs.statSync(path.join(target, name)).size,
  }));
});

Electron 完全可以做得安全,但默认模板若打开 nodeIntegration,前端一处 XSS 就等于本机 RCE。个人工具尤其别省这两行隔离。

4. 案例:做一个「本地 Prompt 柜 + 网关客户端」

需求:

  • 桌面端保存常用 prompt 模板
  • API Key 不进渲染进程明文
  • 实际补全请求打到自己的 LLM Gateway(http://127.0.0.1:4000/v1

推荐拆分:

  1. UI:模板列表、流式输出展示(Web 技术)
  2. command / ipc:读写加密密钥、读模板目录
  3. 网络:主进程或 Rust 侧发 HTTPS(可钉死只允许网关域名)

伪代码(Tauri 侧发请求):

#[tauri::command]
async fn chat_via_gateway(prompt: String) -> Result<String, String> {
    let key = keyring_get("gateway").map_err(|e| e.to_string())?;
    let client = reqwest::Client::new();
    let body = serde_json::json!({
        "model": "auto",
        "messages": [{"role":"user","content": prompt}]
    });
    let resp = client
        .post("http://127.0.0.1:4000/v1/chat/completions")
        .bearer_auth(key)
        .json(&body)
        .send()
        .await
        .map_err(|e| e.to_string())?;
    let v: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
    Ok(v["choices"][0]["message"]["content"].as_str().unwrap_or("").into())
}

这样 UI 进程拿不到长生命周期云厂商 Key;网关虚拟 Key 也只存在安全存储里。

5. 我怎么选(个人项目经验)

  • 验证产品、堆功能快:Electron,但必须 contextIsolation + preload
  • 长期自用、在意体积、密钥敏感:Tauri 2,把能力做成少量 command
  • 核心逻辑尽量与壳无关:本地小服务或共享库,壳只是皮肤——日后换壳成本才可控

桌面框架是载体;个人 AI 工具真正要守住的是:密钥位置、文件边界、网络出口白名单