ファイルをコピー・複製する

fs プラグインの copyFile()(権限 fs:allow-copy-file)でファイルを複製する。同梱の既定ファイルを初回だけ展開する手順、上書きの防ぎ方、フォルダーごとのコピーも示す。

ファイルシステム 対象: Tauri 2.x 更新日: 読了目安: 約10分 fs-011
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 同梱の既定ファイルを初回だけ展開する
  4. 上書きする前にバックアップを残す
  5. フォルダーを中身ごとコピーする
  6. 2. バックエンドから実装する (Rust)
  7. 動作確認
  8. よくあるエラーと対処法
  9. OS ごとの違いと注意点
  10. 関連レシピ

同梱した既定の設定ファイルを初回起動時にアプリのフォルダーへ展開する、保存の前に今のファイルを残しておく、といった複製には File System プラグインの copyFile() を使います。コピーは Rust 側で行われ、中身がプロセス間通信(IPC)を通らないので、読んでから書き直すより速く済みます。注意したいのは、コピー先に同じ名前があると確認なしに上書きされること、フォルダーは渡せないこと、コピー元とコピー先の両方がスコープ(許可するパスの範囲)に入っている必要があることです。Rust からは std::fs::copy() を使います。

前提条件

fs プラグインを追加します。

npm run tauri add fs

copyFile() の権限 fs:allow-copy-file は fs:default に含まれないので追加します。範囲の判定はコピー元とコピー先のそれぞれに行われます。fs:default はアプリ用フォルダー($APPCONFIG・$APPDATA など)とその中身を範囲に含むので、その中どうしなら "fs:allow-copy-file" だけで足ります。同梱ファイルの置き場所 $RESOURCE は含まれないため、下の例では allow で足しています。範囲の書き方は ファイルやディレクトリを削除する にまとめています。fs:allow-write-text-file はバックアップの例の writeTextFile() 用です。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "fs:default",
    "fs:allow-write-text-file",
    {
      "identifier": "fs:allow-copy-file",
      "allow": [{ "path": "$RESOURCE/defaults/*" }]
    }
  ]
}

同梱するファイルは tauri.conf.json の bundle.resources に書きます。配列で書いたパスは src-tauri からの相対パスがそのまま残り、次の例は $RESOURCE/defaults/settings.json に置かれます。パスの中の ../ は _up_ というフォルダー名に置き換わります(画像やDBファイルを配布物に同梱する)。

{
  "bundle": {
    "resources": ["defaults/settings.json"]
  }
}

1. フロントエンドから実装する (TypeScript)

同梱の既定ファイルを初回だけ展開する

同梱したファイルはインストール先に置かれる読み取り用のものなので、ユーザーが変える設定はアプリ用フォルダーへコピーして使います。copyFile() は無条件に上書きするので、2 回目以降の起動で設定を既定値に戻さないよう exists() で確かめます(ファイルやフォルダが存在するか確認する)。コピー先のフォルダーが無いと失敗するため、設定フォルダー自体も作ります。

import { appConfigDir } from '@tauri-apps/api/path';
import { copyFile, exists, mkdir, BaseDirectory } from '@tauri-apps/plugin-fs';

// 初回だけ、同梱の既定設定を設定フォルダーへ展開する。展開したら true
export async function ensureSettingsFile(): Promise<boolean> {
  if (await exists('settings.json', { baseDir: BaseDirectory.AppConfig })) return false; // ユーザーの設定を残す
  await mkdir(await appConfigDir(), { recursive: true }); // 初回は設定フォルダー自体が無いことがある
  await copyFile('defaults/settings.json', 'settings.json', {
    fromPathBaseDir: BaseDirectory.Resource, // $RESOURCE/defaults/settings.json
    toPathBaseDir: BaseDirectory.AppConfig,
  });
  return true;
}

上書きする前にバックアップを残す

保存の直前に今のファイルを日時入りの名前でコピーしておけば、後から戻せます。toISOString() の文字列には : が入り、Windows ではファイル名に使えないので置き換えます。バックアップは増え続けるので、古いものは remove() で間引きます。

import { copyFile, exists, mkdir, writeTextFile, BaseDirectory } from '@tauri-apps/plugin-fs';

const baseDir = BaseDirectory.AppConfig;

// 今の settings.json を backups に日時入りの名前で残してから上書きする
export async function saveWithBackup(text: string): Promise<void> {
  if (await exists('settings.json', { baseDir })) {
    await mkdir('backups', { baseDir, recursive: true });
    const stamp = new Date().toISOString().replace(/[:.]/g, '-'); // 2026-09-12T01-30-00-000Z(UTC)
    await copyFile('settings.json', `backups/settings-${stamp}.json`, {
      fromPathBaseDir: baseDir,
      toPathBaseDir: baseDir,
    });
  }
  await writeTextFile('settings.json', text, { baseDir }); // fs:allow-write-text-file が必要
}

フォルダーを中身ごとコピーする

copyFile() に渡せるのはファイルだけです。フォルダーは readDir() で中身をたどり、1 つずつコピーします(フォルダ内のファイル一覧を取得する)。ファイルの数だけ IPC が起きるので、数が多いなら 2 章の Rust 版を使います。コピー先をコピー元の中に置くと終わらなくなります。

import { sep } from '@tauri-apps/api/path';
import { copyFile, mkdir, readDir, BaseDirectory } from '@tauri-apps/plugin-fs';

// from の中身を to へコピーし、コピーしたファイルの数を返す。シンボリックリンクは飛ばす
export async function copyDir(from: string, to: string, baseDir: BaseDirectory): Promise<number> {
  await mkdir(to, { baseDir, recursive: true });
  let count = 0;
  for (const entry of await readDir(from, { baseDir })) {
    const src = `${from}${sep()}${entry.name}`;
    const dst = `${to}${sep()}${entry.name}`;
    if (entry.isDirectory) {
      count += await copyDir(src, dst, baseDir);
    } else if (entry.isFile) {
      await copyFile(src, dst, { fromPathBaseDir: baseDir, toPathBaseDir: baseDir });
      count++;
    }
  }
  return count;
}

2. バックエンドから実装する (Rust)

std::fs::copy() も上書きします。「無いときだけコピーする」は、File::create_new() が既存のファイルで ErrorKind::AlreadyExists を返すことを使えば、確かめてからコピーするまでの隙間がありません。既定ファイルの展開を setup で行えば、ページが読み込まれる前に済み、JS 側の権限も要りません。Rust の std::fs には capability もスコープも効かないので、JS から受け取った名前は確かめてから使います。

use std::io::ErrorKind;
use std::path::{Component, Path};
use tauri::path::BaseDirectory;
use tauri::Manager;

/// to が無いときだけコピーする。既にあれば上書きせず false
fn copy_if_absent(from: &Path, to: &Path) -> std::io::Result<bool> {
    let mut src = std::fs::File::open(from)?;
    let mut dst = match std::fs::File::create_new(to) {
        Ok(f) => f,
        Err(e) if e.kind() == ErrorKind::AlreadyExists => return Ok(false),
        Err(e) => return Err(e),
    };
    if let Err(e) = std::io::copy(&mut src, &mut dst) {
        drop(dst);
        let _ = std::fs::remove_file(to); // 書きかけのファイルを残さない
        return Err(e);
    }
    Ok(true)
}

/// フォルダーを中身ごとコピーし、ファイルの数を返す。シンボリックリンクは飛ばす
fn copy_dir_all(from: &Path, to: &Path) -> std::io::Result<u64> {
    std::fs::create_dir_all(to)?;
    let mut count = 0;
    for entry in std::fs::read_dir(from)? {
        let entry = entry?;
        let kind = entry.file_type()?; // リンクはたどらない
        let dst = to.join(entry.file_name());
        if kind.is_dir() {
            count += copy_dir_all(&entry.path(), &dst)?;
        } else if kind.is_file() {
            std::fs::copy(entry.path(), &dst)?;
            count += 1;
        }
    }
    Ok(count)
}

/// アプリデータの projects の中で、フォルダーを別の名前で複製する
#[tauri::command]
fn duplicate_project(app: tauri::AppHandle, name: String, new_name: String) -> Result<u64, String> {
    for n in [&name, &new_name] {
        let mut parts = Path::new(n).components();
        if !matches!((parts.next(), parts.next()), (Some(Component::Normal(_)), None)) {
            return Err(format!("invalid name: {n}"));
        }
    }
    let dir = app.path().app_data_dir().map_err(|e| e.to_string())?.join("projects");
    let to = dir.join(&new_name);
    if to.exists() {
        return Err(format!("already exists: {new_name}"));
    }
    copy_dir_all(&dir.join(&name), &to).map_err(|e| e.to_string())
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_fs::init())
        .setup(|app| {
            // 同梱の既定設定を、無いときだけ設定フォルダーへ展開する
            let from = app.path().resolve("defaults/settings.json", BaseDirectory::Resource)?;
            let dir = app.path().app_config_dir()?;
            std::fs::create_dir_all(&dir)?;
            if let Err(e) = copy_if_absent(&from, &dir.join("settings.json")) {
                eprintln!("既定の設定を展開できませんでした: {e}"); // 起動は止めない
            }
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![duplicate_project])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

JS からは、Rust の new_name を newName のように camelCase で渡します。

import { invoke } from '@tauri-apps/api/core';

const copied = await invoke<number>('duplicate_project', { name: 'demo', newName: 'demo-copy' });
console.log(`${copied} 個のファイルをコピーしました`);

動作確認

npm run tauri dev で起動し、ensureSettingsFile() を 2 回呼ぶと true、false の順に返り、2 回目は設定ファイルに触れません。saveWithBackup() を呼ぶたびに、backups に settings-2026-09-12T01-30-00-000Z.json のようなファイルが増えます。Rust の duplicate_project はコピーしたファイルの数を返し、同じ名前でもう一度呼ぶと「already exists: demo-copy」で止まります。

true
false
12 個のファイルをコピーしました

よくあるエラーと対処法

  • 「fs.copy_file not allowed. Permissions associated with this command: fs:allow-app-write, …」: fs:allow-copy-file の追加漏れです(リリースビルドでは「Command plugin:fs|copy_file not allowed by ACL」)。追加して tauri dev を再起動します。
  • 「forbidden path: <パス>, maybe it is not allowed on the scope for allow-copy-file permission in your capability file」: 表示されたパス(コピー元かコピー先)が範囲の外です。$RESOURCE の足し忘れと、fromPathBaseDir / toPathBaseDir の片方を省いて相対パスのまま渡していないかを確かめます。
  • 「failed to copy file from path: …, to path: … with error: …」: コピー元が無い、コピー元がフォルダー、コピー先のフォルダーが無い、コピー先が読み取り専用、のいずれかです。続く OS のメッセージで見分けます。
  • 同梱したはずのファイルが見つからない: 配列で書いた bundle.resources のパスはフォルダー名ごと残るので、defaults/settings.json のようにフォルダー名から渡します。
  • コピーしたらファイルが空になった: コピー元とコピー先が同じファイルを指していると、中身が消えることがあります。基準フォルダーが違っても同じ場所のことがあり、Windows では $APPLOCALDATA と $APPCACHE が同じフォルダーです。

OS ごとの違いと注意点

  • 共通: 中身と一緒にパーミッション(読み取り専用かどうかなど)もコピーされます。読み取り専用のファイルのコピーは読み取り専用になり、後からの上書きに失敗します。外し方は ファイルのパーミッション(権限)を変更する で扱います。Rust の copy_if_absent() は新しくファイルを作るので、元のパーミッションを引き継ぎません。
  • 共通: コピー先の更新日時が元と同じになるとは限りません。いつの状態かを残したいなら、バックアップの例のように名前に日時を入れます。日時の読み方は ファイルのメタデータ(サイズ・作成日時・更新日時)を取得する を参照してください。
  • Windows / macOS: 標準の設定のファイルシステムは大文字と小文字を区別しないので、Memo.txt から memo.txt へのコピーは同じファイルへのコピーになります。
  • macOS / Linux: コピー元がシンボリックリンクなら、リンク先の中身がコピーされ、コピー先は普通のファイルになります。範囲の * と ** は . で始まる名前に一致しないので、隠しファイルをコピーするなら範囲を別に書きます。
  • 共通: 移動が目的なら ファイル名を変更する・別の場所へ移動する の rename() を使います。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する