plugin-fs 安装与配置
# 前端包
npm install @tauri-apps/plugin-fs
# Cargo.toml 添加
# tauri-plugin-fs = "2"
// src-tauri/src/lib.rs
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_fs::init()) // 注册插件
.run(tauri::generate_context!())
.unwrap();
}
// capabilities/default.json — 声明权限
{
"permissions": [
"fs:allow-read-text-file",
"fs:allow-write-text-file",
"fs:allow-read-dir",
"fs:allow-create-dir",
"fs:allow-remove-file",
"fs:allow-stat",
"fs:allow-watch"
]
}
基础文件操作
import {
readTextFile, writeTextFile, readBinaryFile, writeBinaryFile,
exists, remove, rename, copyFile, stat,
} from '@tauri-apps/plugin-fs';
import { appDataDir, join } from '@tauri-apps/api/path';
// 读取文本文件
const content = await readTextFile('/tmp/notes.txt');
// 写入文本文件
await writeTextFile('/tmp/output.txt', 'Hello, Tauri!');
// 检查文件是否存在
const fileExists = await exists('/tmp/notes.txt');
// 获取文件元数据
const fileInfo = await stat('/tmp/notes.txt');
console.log(fileInfo.size, fileInfo.mtime);
// 删除文件
await remove('/tmp/old-file.txt');
// 重命名/移动文件
await rename('/tmp/old.txt', '/tmp/new.txt');
应用数据目录
桌面应用不应将数据直接写入 /tmp 等临时目录,而应使用系统分配给应用的专属目录:
appDataDir
应用私有数据目录。macOS:
~/Library/Application Support/<identifier>/,Windows: %APPDATA%/<identifier>/,Linux: ~/.config/<identifier>/appLocalDataDir
本地(非漫游)数据目录。Windows 上区分本地与漫游数据,macOS/Linux 与 appDataDir 相同。
appCacheDir
缓存目录,数据可被系统清除。macOS:
~/Library/Caches/,Linux: ~/.cache/appLogDir
日志文件目录。macOS:
~/Library/Logs/documentsDir
用户文档目录(~/Documents)。存放用户可见的文件,适合导出、保存用户文件。
import { appDataDir, join, documentsDir } from '@tauri-apps/api/path';
import { readTextFile, writeTextFile, mkdir, exists } from '@tauri-apps/plugin-fs';
// 在应用数据目录中保存配置
async function saveConfig(config: object) {
const dataDir = await appDataDir();
// 确保目录存在
if (!(await exists(dataDir))) {
await mkdir(dataDir, { recursive: true });
}
const configPath = await join(dataDir, 'config.json');
await writeTextFile(configPath, JSON.stringify(config, null, 2));
}
// 读取配置
async function loadConfig() {
const configPath = await join(await appDataDir(), 'config.json');
if (!(await exists(configPath))) return {};
return JSON.parse(await readTextFile(configPath));
}
目录遍历
import { readDir, DirEntry } from '@tauri-apps/plugin-fs';
async function listFiles(dirPath: string) {
const entries: DirEntry[] = await readDir(dirPath);
for (const entry of entries) {
if (entry.isDirectory) {
console.log('DIR:', entry.name);
// 递归遍历子目录
await listFiles(`${dirPath}/${entry.name}`);
} else {
console.log('FILE:', entry.name);
}
}
}
文件监听(watch)
import { watch, WatchEvent } from '@tauri-apps/plugin-fs';
// 监听文件或目录变化
const unwatch = await watch(
'/path/to/watch',
(event: WatchEvent) => {
console.log('Changed:', event.paths, event.type);
// event.type: Create | Modify | Remove | Access | Any
},
{
recursive: true, // 是否递归监听子目录
delayMs: 500, // 防抖延迟(毫秒)
}
);
// 停止监听
await unwatch();
文件对话框
# 安装
npm install @tauri-apps/plugin-dialog
import { open, save, ask, confirm, message } from '@tauri-apps/plugin-dialog';
// 打开文件选择对话框
const filePath = await open({
multiple: false,
filters: [{
name: 'Images',
extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'],
}],
defaultPath: await documentsDir(),
}); // 返回选中的路径字符串,或 null(用户取消)
// 打开目录选择对话框
const dirPath = await open({ directory: true });
// 保存文件对话框
const savePath = await save({
filters: [{ name: 'Text', extensions: ['txt'] }],
defaultPath: 'output.txt',
});
// 消息弹窗
await message('File saved successfully!', { title: 'Success', kind: 'info' });
// 确认对话框
const confirmed = await confirm('Are you sure you want to delete this file?', {
title: 'Confirm Delete',
kind: 'warning',
});
剪贴板操作
npm install @tauri-apps/plugin-clipboard-manager
import { readText, writeText } from '@tauri-apps/plugin-clipboard-manager';
// 写入剪贴板
await writeText('Copied to clipboard!');
// 读取剪贴板内容
const clipboardText = await readText();
console.log('Clipboard:', clipboardText);
路径安全与权限范围
Tauri 的文件系统权限可以配置 scope 来限制可访问的路径范围,防止应用越界访问敏感系统目录。建议在 capabilities 中配置 "fs:scope" 字段,只允许访问应用数据目录和用户文档目录,而非整个文件系统。
fs:scope 路径限制
Tauri 的 fs:scope 权限允许精确限制前端代码可以访问哪些路径,是防止任意文件读取攻击的关键安全机制:
// src-tauri/capabilities/default.json
{
"identifier": "default",
"windows": ["main"],
"permissions": [
"fs:allow-read-text-file",
"fs:allow-write-text-file",
"fs:allow-read-dir",
"fs:allow-create-dir",
{
"identifier": "fs:scope",
"allow": [
"$APPDATA/**", // 允许应用数据目录
"$DOCUMENT/**", // 允许文档目录
"$DOWNLOAD/**" // 允许下载目录
],
"deny": [
"$HOME/.ssh/**", // 明确拒绝 SSH 密钥
"$HOME/.gnupg/**" // 明确拒绝 GPG 密钥
]
}
]
}
$APPDATA
应用专属数据目录,等同于
appDataDir()。$DOCUMENT
用户文档目录,等同于
documentsDir()。$DOWNLOAD
用户下载目录,等同于
downloadDir()。$HOME
用户主目录,等同于
homeDir()。应谨慎授予此范围。Rust 端复杂文件操作
对于需要高性能或复杂逻辑的文件操作(如递归搜索、计算哈希、批量处理),应在 Rust 侧实现,再通过 Command 暴露给前端:
use std::path::Path;
use std::fs;
use tauri::command;
/// 递归列举目录下所有文件,可按扩展名过滤
#[command]
pub fn list_files_recursive(
dir: String,
extension: Option<String>, // 可选的扩展名过滤,如 Some("txt")
) -> Result<Vec<String>, String> {
let mut result = Vec::new();
collect_files(Path::new(&dir), &extension, &mut result)
.map_err(|e| e.to_string())?;
Ok(result)
}
// 内部递归实现(不暴露给前端)
fn collect_files(
dir: &Path,
ext_filter: &Option<String>,
result: &mut Vec<String>,
) -> std::io::Result<()> {
// 读取目录内容
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
// 目录则递归进入
collect_files(&path, ext_filter, result)?;
} else if path.is_file() {
// 文件则检查扩展名过滤
let matches = match ext_filter {
Some(ext) => path
.extension()
.and_then(|e| e.to_str())
.map(|e| e == ext)
.unwrap_or(false),
None => true, // 无过滤,所有文件都包含
};
if matches {
result.push(path.to_string_lossy().to_string());
}
}
}
Ok(())
}
use std::io::{Read, BufReader};
/// 计算文件的 MD5 哈希值(用于完整性校验)
#[command]
pub fn compute_file_hash(path: String) -> Result<String, String> {
use std::fs::File;
// 打开文件,使用 BufReader 提升读取性能
let file = File::open(&path).map_err(|e| e.to_string())?;
let mut reader = BufReader::new(file);
let mut buffer = Vec::new();
// 读取全部内容(生产环境大文件应分块读取)
reader.read_to_end(&mut buffer).map_err(|e| e.to_string())?;
// 计算 MD5(需要在 Cargo.toml 添加 md5 = "0.7" 依赖)
let digest = md5::compute(&buffer);
Ok(format!("{:x}", digest))
}
import { invoke } from '@tauri-apps/api/core';
// 前端调用:列举所有 txt 文件
const txtFiles = await invoke<string[]>('list_files_recursive', {
dir: '/Users/username/Documents',
extension: 'txt',
});
console.log('Found:', txtFiles.length, 'txt files');
// 验证下载文件完整性
const hash = await invoke<string>('compute_file_hash', {
path: '/tmp/downloaded-file.zip',
});
if (hash !== expectedHash) {
throw new Error('File integrity check failed!');
}
二进制文件操作
对于图片、音频等二进制文件,使用 readFile/writeFile 处理 Uint8Array:
import { readFile, writeFile } from '@tauri-apps/plugin-fs';
import { join, appDataDir } from '@tauri-apps/api/path';
// 读取图片为二进制数据
const imageData: Uint8Array = await readFile('/path/to/image.png');
// 将二进制数据转为 base64 用于 img src
const base64 = btoa(String.fromCharCode(...imageData));
const dataUrl = `data:image/png;base64,${base64}`;
// 写入二进制文件(将 canvas 内容保存为 PNG)
const canvas = document.getElementById('canvas') as HTMLCanvasElement;
const blob = await new Promise<Blob>(resolve => canvas.toBlob(b => resolve(b!)));
const arrayBuffer = await blob.arrayBuffer();
const savePath = await join(await appDataDir(), 'screenshot.png');
await writeFile(savePath, new Uint8Array(arrayBuffer));
完整实战:文本编辑器文件管理
综合运用文件对话框、文件读写和应用数据目录,实现一个带最近文件列表的文本编辑器:
import { useState, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { open, save } from '@tauri-apps/plugin-dialog';
import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs';
import { join, appDataDir } from '@tauri-apps/api/path';
const RECENT_FILES_KEY = 'recent-files.json';
const MAX_RECENT = 10;
export function useFileManager() {
const [currentPath, setCurrentPath] = useState<string | null>(null);
const [content, setContent] = useState('');
const [recentFiles, setRecentFiles] = useState<string[]>([]);
const [isDirty, setIsDirty] = useState(false); // 有未保存修改
// 加载最近文件列表
useEffect(async () => {
try {
const dataDir = await appDataDir();
const filePath = await join(dataDir, RECENT_FILES_KEY);
const data = JSON.parse(await readTextFile(filePath));
setRecentFiles(data);
} catch {
// 首次运行,文件不存在
}
}, []);
// 打开文件
async function openFile() {
const path = await open({
filters: [{ name: 'Text', extensions: ['txt', 'md', 'json'] }],
});
if (!path) return; // 用户取消
const text = await readTextFile(path as string);
setContent(text);
setCurrentPath(path as string);
setIsDirty(false);
await addToRecent(path as string);
}
// 保存文件(另存为)
async function saveAs() {
const path = await save({
filters: [{ name: 'Text', extensions: ['txt'] }],
defaultPath: 'document.txt',
});
if (!path) return; // 用户取消
await writeTextFile(path, content);
setCurrentPath(path);
setIsDirty(false);
await addToRecent(path);
}
// 直接保存(覆盖当前文件)
async function saveFile() {
if (!currentPath) { await saveAs(); return; }
await writeTextFile(currentPath, content);
setIsDirty(false);
}
// 添加到最近文件列表并持久化
async function addToRecent(path: string) {
const updated = [path, ...recentFiles.filter(p => p !== path)].slice(0, MAX_RECENT);
setRecentFiles(updated);
const dataDir = await appDataDir();
await writeTextFile(await join(dataDir, RECENT_FILES_KEY), JSON.stringify(updated));
}
return { content, setContent, currentPath, isDirty, setIsDirty, recentFiles, openFile, saveFile, saveAs };
}
文件监听实战:热重载配置
使用文件监听实现配置文件热重载——当用户在外部编辑器修改配置文件时,应用自动读取新配置:
import { useEffect, useRef } from 'react';
import { watch } from '@tauri-apps/plugin-fs';
import { readTextFile } from '@tauri-apps/plugin-fs';
import { join, appDataDir } from '@tauri-apps/api/path';
function useConfigWatcher(onConfigChange: (config: object) => void) {
const unwatchRef = useRef<(() => Promise<void>) | null>(null);
useEffect(() => {
let active = true;
(async () => {
const configPath = await join(await appDataDir(), 'config.json');
// 启动监听,500ms 防抖避免频繁触发
unwatchRef.current = await watch(
configPath,
async (event) => {
if (!active) return;
if (event.type === 'Modify' || event.type === 'Create') {
try {
const raw = await readTextFile(configPath);
onConfigChange(JSON.parse(raw));
} catch (e) {
console.warn('Config parse error:', e);
}
}
},
{ delayMs: 500 } // 防止保存时多次触发
);
})();
// 组件卸载时停止监听
return () => {
active = false;
unwatchRef.current?.();
};
}, [onConfigChange]);
}
本章小结
本章核心要点
- plugin-fs 是核心文件插件:提供 readTextFile/writeTextFile/readFile/writeFile/readDir/watch 等完整的文件系统 API;需要在 Cargo.toml 和 capabilities 中同时配置。
- 使用应用专属目录:配置文件存
appDataDir(),缓存存appCacheDir(),日志存appLogDir(),用户可见文件存documentsDir();不要直接写 /tmp。 - fs:scope 限制访问范围:在 capabilities 中用
allow/deny数组精确控制可访问路径,防止越界读取敏感文件;用$APPDATA/$DOCUMENT等变量引用系统目录。 - 复杂操作在 Rust 侧实现:递归遍历、哈希计算、批量处理等逻辑应放在 Rust Command 中,充分利用 Rust 的性能和标准库;前端只负责 UI 和调用。
- 文件监听防抖:watch() 的 delayMs 选项用于防抖,避免保存时触发多次回调;在 React useEffect cleanup 中调用 unwatch() 防止内存泄漏。
- 二进制文件用 readFile/writeFile:返回/接受 Uint8Array,配合 btoa/ArrayBuffer 可在前端显示图片或处理二进制数据。