LOADING

页面加载中...

给带后端的 Web 项目套上 Tauri 外壳

用 sidecar 给带后端的 Web 应用套上 Tauri 外壳

很多 Web 应用已经拥有完整的后端:它负责 HTTP API、WebSocket、文件存储、任务执行和本地预览。把这类应用桌面化时,最省风险的路径通常不是重写后端,而是让 Tauri 管理一个 sidecar 进程,再让窗口访问这个本地服务。

先确定边界

Tauri 负责窗口、应用生命周期、打包资源和桌面能力;后端继续负责原有的业务协议。只要后端通过 HTTP 或 WebSocket 提供服务,前端就可以继续使用相对路径请求 API,桌面端不需要复制一套 Rust API。

Tauri 窗口  ── HTTP/WebSocket ──>  本地后端 sidecar
    │                                  │
    └── 启动、等待就绪、退出清理       └── API、任务、文件、预览

这个边界也决定了迁移策略:先让后端在命令行中稳定运行,再把启动和停止交给 Tauri。

让后端支持可配置的工作目录

不要把用户数据写到安装目录。桌面安装目录可能只读,也可能在升级后被替换。后端最好已经支持类似下面的参数:

backend --root <user-data-dir> --host 127.0.0.1 --port 0

root 用于保存配置、任务记录、附件和生成结果;静态资源可以作为只读打包资源提供,也可以在首次启动时复制到工作目录。若后端原本只接受当前目录,应优先在外壳侧准备一个完整的工作目录,避免为了桌面版修改后端协议。

构建 sidecar 并按目标平台命名

Tauri 通过文件名找到 sidecar。文件名必须包含当前构建目标的 Rust target triple,例如:

webweave-backend-x86_64-pc-windows-msvc.exe
webweave-backend-aarch64-apple-darwin
webweave-backend-x86_64-unknown-linux-gnu

构建脚本需要完成三件事:读取 rustc -vV 的 host、创建 src-tauri/binaries、调用后端语言的编译器输出目标文件。不要把 .exe 固定写进所有平台的脚本逻辑。

下面是一个 Node.js 构建脚本:

// scripts/build-sidecar.mjs
import { mkdirSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";

const scriptsDir = dirname(fileURLToPath(import.meta.url));
const root = resolve(scriptsDir, "..");
const outputDir = join(root, "src-tauri", "binaries");
const sourceDir = join(root, "backend");

function run(command, args, options = {}) {
  const result = spawnSync(command, args, { stdio: "inherit", ...options });
  if (result.error) throw result.error;
  if (result.status !== 0) throw new Error(`${command} failed`);
}

const rustc = spawnSync("rustc", ["-vV"], { encoding: "utf8" });
if (rustc.status !== 0) throw new Error("rustc is required");
const target = rustc.stdout.match(/^host:\s*(\S+)\s*$/m)?.[1];
if (!target) throw new Error("Rust target triple was not found");

const suffix = process.platform === "win32" ? ".exe" : "";
const output = join(outputDir, `backend-${target}${suffix}`);
mkdirSync(outputDir, { recursive: true });
run("go", ["build", "-trimpath", "-o", output, "."], { cwd: sourceDir });

对应的 package.json 脚本可以保持简单:

{
  "scripts": {
    "build:sidecar": "node scripts/build-sidecar.mjs",
    "dev": "pnpm build:sidecar && tauri dev",
    "build": "pnpm build:sidecar && tauri build"
  }
}

Tauri 配置声明不带平台后缀的逻辑名称,CLI 会根据当前目标寻找实际文件:

{
  "bundle": {
    "externalBin": ["binaries/backend"],
    "resources": {
      "../frontend/": "webui/"
    }
  }
}

把资源和用户数据分开

桌面应用通常有两类路径:

  • 只读资源:前端页面、默认 Skills、图标和其他随安装包发布的文件。
  • 用户数据:配置、历史记录、生成文件、附件和运行日志。

只读资源放在 Tauri 的 resource 目录,用户数据使用 app_data_dir 或同等平台目录。首次启动时可以把默认资源复制到用户目录,但要使用“目标文件不存在才复制”的策略,避免覆盖用户修改过的内容。

Rust 侧可以用一个递归复制函数完成首次初始化:

use std::{fs, path::Path};

fn copy_tree(source: &Path, target: &Path) -> std::io::Result<()> {
    fs::create_dir_all(target)?;
    for entry in fs::read_dir(source)? {
        let entry = entry?;
        let destination = target.join(entry.file_name());
        if entry.file_type()?.is_dir() {
            copy_tree(&entry.path(), &destination)?;
        } else if !destination.exists() {
            fs::copy(entry.path(), destination)?;
        }
    }
    Ok(())
}

调用时把资源目录和应用数据目录分开:

let data_dir = app.path().app_data_dir()?;
let resource_dir = app.path().resource_dir()?;
let webui = data_dir.join("webui");

if !webui.join("index.html").exists() {
    copy_tree(&resource_dir.join("webui"), &webui)?;
}

实际项目还应为“资源升级”设计版本标记。只判断文件是否存在适合默认资源不变的场景;如果资源需要随版本更新,可以在数据目录保存 resource-version,再按版本执行迁移。

启动 sidecar 并等待就绪

Tauri 启动 sidecar 后,不能立即导航到窗口地址。后端进程还可能没有完成监听。一个简单可靠的协议是让后端在成功监听后输出:

Web app started url=http://127.0.0.1:53142

Tauri 读取 stdout 或 stderr,解析地址后再让窗口导航。日志格式最好使用稳定的键值字段;如果日志会本地化,建议增加专用的机器可读就绪行,避免依赖自然语言文本。

端口使用 0 让操作系统分配,可以避免固定端口冲突。后端必须输出实际端口,而不是配置中的 0。

Go 后端的最小启动代码如下:

listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
    return err
}

log.Printf("WEBWEAVE_READY url=http://%s", listener.Addr())
return http.Serve(listener, handler)

如果使用结构化日志,建议单独保留一条稳定的就绪事件:

slog.Info("sidecar ready", "url", "http://"+listener.Addr().String())

Rust 侧只解析这条机器可读事件,不要依赖普通业务日志的中文或英文描述:

if let CommandEvent::Stderr(bytes) = event {
    let line = String::from_utf8_lossy(&bytes);
    if let Some(url) = line.split("url=").nth(1).and_then(|s| s.split_whitespace().next()) {
        window.navigate(url.parse()?)?;
        break;
    }
}

处理启动页和失败状态

Tauri 的 frontendDist 可以指向一个很小的启动页。窗口先显示“正在启动”,后端就绪后再导航到本地服务。这样可以避免前端在服务尚未启动时反复请求 API。

启动流程至少要处理三种结果:

  1. 后端输出就绪地址,窗口加载应用。
  2. 后端提前退出,窗口显示错误和日志位置。
  3. 启动超时,终止子进程并提示用户检查环境或权限。

启动器可以把 sidecar 保存到应用状态中,并在退出事件中清理:

let (mut events, child) = app
    .shell()
    .sidecar("backend")?
    .args([
        "--root".to_string(), data_dir.to_string_lossy().into_owned(),
        "--port".to_string(), "0".to_string(),
    ])
    .spawn()?;

app.manage(std::sync::Mutex::new(Some(child)));

tauri::async_runtime::spawn(async move {
    while let Some(event) = events.recv().await {
        // 读取 ready 事件并导航窗口
    }
});

退出时取出句柄并等待清理:

app.run(|handle, event| {
    if let tauri::RunEvent::Exit = event {
        if let Ok(mut child) = handle
            .state::<std::sync::Mutex<Option<CommandChild>>>()
            .lock()
        {
            if let Some(process) = child.take() {
                let _ = process.kill();
            }
        }
    }
});

在退出时清理整个进程树

只保存子进程句柄并调用 kill,未必能清理后端启动的预览进程。Windows 应使用 job object 或 Tauri shell 插件提供的进程组能力;Unix 平台则需要进程组或明确的子进程回收策略。退出清理应该等待进程真正结束,避免下次开发构建或删除临时目录时遇到文件锁。

CSP、WebSocket 和本地地址

如果窗口最终访问 http://127.0.0.1:<port>,CSP 至少要允许:

  • http://127.0.0.1:* 的页面和 API 请求
  • ws://127.0.0.1:* 的 WebSocket
  • 前端实际使用的 HTTPS、图片和字体来源

预览页面如果还会打开 WebSocket,需要确认预览路由不会把连接错误地发到主界面地址。桌面 WebView 的安全策略应尽量收窄到实际使用的来源,不要为了快速调通而永久放开所有协议。

一个最小 CSP 例子如下:

{
  "app": {
    "security": {
      "csp": "default-src 'self' asset: http://127.0.0.1:*; connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*; img-src 'self' data: blob: http://127.0.0.1:*; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval'"
    }
  }
}

生产环境应根据实际资源来源收紧策略。unsafe-eval、外部 CDN 和任意网络来源都应该逐项确认,而不是直接复制到所有项目。

开发和发布验证

建议按下面顺序验证:

1. 单独运行后端并访问健康检查
2. 单独运行 sidecar 构建脚本
3. cargo check
4. 启动 Tauri 开发窗口
5. 创建任务、上传文件、启动预览并停止预览
6. 构建安装包并在干净环境启动

测试重点不是窗口能否打开,而是完整生命周期:数据目录是否可写、重启后历史是否保留、WebSocket 是否正常、预览进程是否能停止、应用退出后是否没有残留进程。

可以先用一个健康检查做自动化冒烟测试:

$response = Invoke-RestMethod "http://127.0.0.1:$port/api/health"
if (-not $response.ok) {
    throw "sidecar health check failed"
}

Linux 或 macOS 中对应的命令是:

curl --fail "http://127.0.0.1:${PORT}/api/health"

结语

sidecar 方案的价值在于把桌面适配限制在进程生命周期、资源路径和窗口加载这几个边界上。成熟的后端继续服务原有的浏览器和桌面入口,Tauri 则提供一个轻量的桌面容器。只要提前定义好工作目录、就绪协议、目标文件命名和退出清理,带后端的 Web 应用就可以在不重写业务逻辑的情况下获得桌面发行能力。