芯的价值在接口:把操作系统能力包装成安全、好用的命令。本节过三大件——文件读写、网络请求、外部进程——每件都先给"正姿势",再点破对应的"险姿势"。读完本节,你封装的每个能力都自带两样东西:路径与输入校验、明确的失败语义。
自带笔记的笔记本体是本地目录下的 Markdown 文件。封装读写的最小可用版:
use std::path::{Path, PathBuf}; fn notes_dir(app: tauri::AppHandle) -> Result<PathBuf, String> { let dir = app.path().app_data_dir().map_err(|e| e.to_string())?; Ok(dir.join("notes")) } fn ensure_within(base: &Path, target: &Path) -> Result<(), String> { if !target.starts_with(base) { return Err("路径越界".into()); // 拒绝逃出笔记目录的任何路径 } Ok(()) } #[tauri::command] fn read_note(app: tauri::AppHandle, name: String) -> Result<String, String> { let base = notes_dir(app)?; let target = base.join(&name); ensure_within(&base, &target)?; std::fs::read_to_string(&target).map_err(|e| format!("读取失败: {e}")) }
三个要点拆开看。目录从 AppHandle 取而非手拼路径——app_data_dir 返回系统约定的应用数据目录(随 identifier 走),跨平台正确且不用关心权限。ensure_within 是路径越界的闸门:前端传来的 name 若含上跳段(拼出父目录路径),starts_with 检查直接拒绝。这是命令输入校验里最重要的一条,凡是接受路径参数的命令都必须有它,第 6 章安全章会再强调。错误带上下文:把系统错误转成"读取失败加原因",前端能直接展示。
顺带说清"自写命令还是用官方 fs 插件"的分工:fs 插件(第 7 章)给前端直接的文件 API 加目录作用域,适合"界面要读写用户选的文件"这类通用需求;自写命令适合带业务语义的封装——"读取笔记"含格式解析与越界检查,是业务不是通用文件访问。两者不冲突,业务封装常常内部仍用标准库。
芯侧发请求用 reqwest(Tauri 生态事实标准)。要封装的不是"发出去",而是三件容易被漏掉的事:超时、重试、错误分级:
use std::time::Duration; pub struct Http { client: reqwest::Client, } impl Http { pub fn new() -> Self { Self { client: reqwest::Client::builder() .timeout(Duration::from_secs(15)) // 总超时:慢接口不再无限等 .connect_timeout(Duration::from_secs(5)) .build() .expect("client 构建失败"), } } pub async fn get_json(&self, url: &str) -> Result<serde_json::Value, String> { let mut last_err = String::new(); for attempt in 0..3 { match self.client.get(url).send().await { Ok(resp) if resp.status().is_success() => { return resp.json().await.map_err(|e| format!("解析失败: {e}")); } Ok(resp) => return Err(format!("服务端错误: {}", resp.status())), Err(e) => { last_err = e.to_string(); tokio::time::sleep(Duration::from_millis(300 * (attempt + 1))).await; } } } Err(format!("网络不可达: {last_err}")) } }
取舍说明:只对网络层瞬态错误重试(连接失败),服务端明确报错不重试——重试语义错了会把"参数错误"放大成三次请求。超时是默认必配——桌面应用的网络请求没有浏览器页签的"用户会自己刷新"兜底,15 秒总超时是体面的上限。最后给前端留什么粒度:命令返回业务结果或"带原因的失败",不要把 reqwest 的错误类型直接序列化出去——实现细节泄漏到前端,换库即破坏契约。
"调用外部程序"有两类场景。场景一:系统命令(打开文件管理器、调 ffmpeg 转码)——优先用官方 shell 或 opener 插件的能力面,别手写进程管理。场景二:随应用分发的辅助程序(侧车,sidecar)——比如内置一个小型转码器。侧车三步走:Cargo 或 npm 配置里登记外部二进制名;打包时把对应平台二进制放进指定目录随包分发(第 8 章);运行时用 shell 插件按侧车方式拉起并读输出:
use tauri_plugin_shell::ShellExt; #[tauri::command] async fn transcode(app: tauri::AppHandle, input: String) -> Result<String, String> { let output = app.shell() .sidecar("my-encoder") .map_err(|e| e.to_string())? .args(["--input", &input, "--fast"]) .output() .await .map_err(|e| format!("启动失败: {e}"))?; if output.status.success() { Ok(String::from_utf8_lossy(&output.stdout).into_owned()) } else { Err(format!("转码失败: {}", String::from_utf8_lossy(&output.stderr))) } }
进程封装的红线只有一条:参数必须是结构化的(args 数组逐项传),永远不要把用户输入拼进一条 shell 字符串再执行——那是把命令注入的口子亲手开在芯里。
三大件装上,芯的能力面齐了。下一道上质检工序:给所有失败路径一个体面的、可被前端分流处理的错误体系。