Persisted Scope で権限状態を維持する

persisted-scope プラグインで、ダイアログやドロップで許可されたパスを保存し、再起動後も fs プラグインで読めるようにする。fs より後に登録する理由と、許可の一覧・取り消し方も示す。

プラグイン拡張 対象: Tauri 2.x 更新日: 読了目安: 約8分 plugin-013
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 2. バックエンドから実装する (Rust)
  4. 動作確認
  5. よくあるエラーと対処法
  6. OS ごとの違いと注意点
  7. 関連レシピ

ダイアログで選ばれたファイルは、その起動中だけ fs プラグインで読み書きできます。アプリを終了すると許可は消えるので、保存しておいたパスを次の起動で読むと「forbidden path: 」で始まるエラーになります。Persisted Scope プラグインは、実行中に加わった許可をファイルに保存し、次の起動で元に戻します。登録するだけで動く Rust のプラグインで、JS の API はありません。

前提条件

npm run tauri add persisted-scope

入るのはクレートと lib.rs の登録だけで、npm パッケージと権限はありません(全体の流れは プラグインをインストールして有効化する)。capabilities に persisted-scope:default を書くと、ビルドがエラーで止まります。

保存されるのは、実行中に fs プラグインの許可範囲(スコープ)へ加わったパスです。

許可が加わるきっかけ保存される範囲
open() / save() でファイルが選ばれたそのファイル
open({ directory: true }) でフォルダが選ばれたフォルダと直下(recursive: true なら配下すべて)
ウィンドウにドロップされたファイルならそれだけ、フォルダなら配下すべて
Rust で fs_scope().allow_file() などを呼んだ指定した範囲

保存されないのは、fs:allow-read-text-file のようなコマンドの許可です。これは capabilities に書いたものが毎回使われるので、Persisted Scope を入れても書いておく必要があります。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "dialog:allow-open",
    "fs:allow-read-text-file"
  ]
}

登録は必ず fs プラグインより後にします。fs より先に初期化されると、保存も復元もされません。tauri add は新しいプラグインを先頭に差し込むので、fs を入れた後に追加すると逆順になります(2 章のコード)。

ほかの方法との使い分けです。

やりたいこと方法
前回選ばれたファイルやフォルダを黙って読み書きするPersisted Scope
前回の場所から選び直してもらう保存したパスを defaultPath に渡す(dlg-006)
決まった用途のファイルだけ読むRust のコマンドで読む(dlg-004)
アプリの設定やキャッシュ$APPDATA などは fs:default で最初から範囲内(fs-017)

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

JS 側で特別なことはしません。ダイアログで選んだ時点で許可が保存され、次の起動では「最近使ったファイル」のパスをそのまま読めます。ただし、許可を取り消した後や、プラグインを入れる前に控えたパスは読めません。「forbidden path」のときは、そのパスを初期値にしてダイアログを出し直す作りにしておきます。

import { open } from '@tauri-apps/plugin-dialog';
import { readTextFile } from '@tauri-apps/plugin-fs';

const KEY = 'recent-files';

export function recentFiles(): string[] {
  try {
    return JSON.parse(localStorage.getItem(KEY) ?? '[]') as string[];
  } catch {
    return [];
  }
}

function remember(path: string) {
  const list = [path, ...recentFiles().filter((p) => p !== path)].slice(0, 10);
  localStorage.setItem(KEY, JSON.stringify(list));
}

// ダイアログで選ぶ。選ばれたファイルの許可は Persisted Scope が保存する
export async function openWithDialog(defaultPath?: string): Promise<string | null> {
  const path = await open({ defaultPath, filters: [{ name: 'テキスト', extensions: ['txt', 'md'] }] });
  if (path === null) return null; // キャンセル
  remember(path);
  return readTextFile(path);
}

// 「最近使ったファイル」から開く。許可が無ければ、そのパスを初期値にして選び直してもらう
export async function openRecent(path: string): Promise<string | null> {
  try {
    return await readTextFile(path);
  } catch (e) {
    if (!String(e).includes('forbidden path')) throw e; // 移動・削除などは別の理由
    return openWithDialog(path);
  }
}

フォルダも同じで、open({ directory: true, recursive: true }) で選ばれたフォルダは配下ごと保存されます。

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

登録の順番と、許可の一覧・取り消しのコマンドです。許可は .persisted-scope というファイルに、許可が加わるたびに丸ごと保存されます。場所は app_data_dir()($APPDATA)の直下で、fs プラグインからは読み書きできないよう守られています。

1 つずつ取り消す API はありません。fs_scope().forbid_file() で禁止はできますが、禁止は許可より常に優先されるため、そのファイルはダイアログで選び直しても読めなくなります。禁止しただけでは保存も行われず、次に何かが許可されたときにまとめて保存されます。履歴を消す用途には使わず、すべてを忘れさせる次のコマンドを使います。

use tauri::Manager;
use tauri_plugin_fs::FsExt;

/// 今の起動中に有効な許可(前回から引き継いだ分を含む)を一覧にする
#[tauri::command]
fn remembered_paths(app: tauri::AppHandle) -> Vec<String> {
    let mut list: Vec<String> = app
        .fs_scope()
        .allowed_patterns()
        .iter()
        .map(|p| p.to_string())
        .filter(|p| !p.starts_with(r"\\?\")) // Windows では同じパスが \\?\ 付きでも入っている
        .collect();
    list.sort();
    list
}

/// 保存した許可をすべて消し、アプリを再起動して白紙に戻す
#[tauri::command]
fn forget_all_paths(app: tauri::AppHandle) -> Result<(), String> {
    let file = app
        .path()
        .app_data_dir()
        .map_err(|e| e.to_string())?
        .join(".persisted-scope");
    match std::fs::remove_file(&file) {
        Ok(()) => {}
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
        Err(e) => return Err(format!("{}: {e}", file.display())),
    }
    // 今の起動中の許可はメモリに残り、次に許可が加わると丸ごと保存し直されるため再起動する
    app.request_restart();
    Ok(())
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_dialog::init())
        .plugin(tauri_plugin_fs::init())
        .plugin(tauri_plugin_persisted_scope::init()) // 必ず fs より後
        .invoke_handler(tauri::generate_handler![remembered_paths, forget_all_paths])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
import { invoke } from '@tauri-apps/api/core';

// 設定画面に「アクセスを許可したファイル」として並べる
console.log((await invoke<string[]>('remembered_paths')).join('\n'));

// 「許可をすべて取り消す」ボタンから呼ぶ。アプリが再起動する
export async function forgetAll() {
  await invoke('forget_all_paths');
}

動作確認

npm run tauri dev で起動し、openWithDialog() でファイルを選んでからアプリを終了します。もう一度起動して openRecent(recentFiles()[0]) を呼ぶと、ダイアログを出さずに中身が返ります。remembered_paths は次のように出ます(Windows の例。フォルダは recursive: true で選んだもの)。

C:\Users\me\Documents\memo.txt
C:\Users\me\Projects\notes
C:\Users\me\Projects\notes\**

forget_all_paths を呼んだ後の起動では、同じ openRecent() がダイアログを出すようになります。

よくあるエラーと対処法

  • 再起動後に「forbidden path: 」で始まるエラーになる: プラグインが登録されていないか、fs より前に登録されています。後者なら tauri dev のターミナルに「Please make sure to register the fs plugin before the persisted-scope plugin!」と出ます。
  • ビルドが「Permission persisted-scope:default not found, expected one of」で始まるエラーで止まる: このプラグインに権限はありません。capabilities から消します。
  • 「fs.read_text_file not allowed. Permissions associated with this command:」の後に候補が並ぶ: コマンドの許可が capabilities にありません。保存されるのはパスの範囲だけです。
  • 「failed to open file at path:」で始まるエラー: 許可はありますが、ファイルが移動・削除されています。許可はパスごとなので、新しい場所は選び直してもらいます。

OS ごとの違いと注意点

  • 保存先: $APPDATA は Windows では ~\AppData\Roaming\<identifier>、macOS では ~/Library/Application Support/<identifier>、Linux では ~/.local/share/<identifier> です。identifier を変えると別のファイルになり、許可は引き継がれません(開発版だけ変える場合も同じです)。
  • iOS / Android: プラグインは対応していますが、選ばれたファイルの返り方がデスクトップと違う(Android は content:// の URI、iOS は既定でアプリ内へのコピー)ので、dlg-004 の注意と合わせて確かめます。
  • 画像の表示: convertFileSrc() で読み込む asset プロトコルの範囲も保存するなら、tauri-plugin-persisted-scope = { version = "2", features = ["protocol-asset"] } にします(asset プロトコル自体の有効化は別に必要です)。
  • 安全面: 許可は増える一方で期限もなく、ページが乗っ取られたときに読まれる範囲も広がり続けます。フォルダのドロップや recursive: true は配下すべてが残ります。fs:allow-remove のような書き込み系の権限も持たせていれば、覚えた範囲はすべて削除の対象にもなります(ファイルやディレクトリを削除する)。必要なアプリだけで使い、取り消す手段を用意します。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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