Chapter 06

文件系统与系统 API

使用 plugin-fs 读写文件,掌握路径、对话框、剪贴板等系统级 API

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]);
}

本章小结

本章核心要点