用 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。
启动流程至少要处理三种结果:
- 后端输出就绪地址,窗口加载应用。
- 后端提前退出,窗口显示错误和日志位置。
- 启动超时,终止子进程并提示用户检查环境或权限。
启动器可以把 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 应用就可以在不重写业务逻辑的情况下获得桌面发行能力。