保存先のパスを組み立てる、ダイアログで選ばれたファイルの拡張子で処理を分ける、ユーザーが入力したフォルダー名を足す、といったパスの操作は、文字列の連結ではなく専用の関数で行います。フロントエンドは @tauri-apps/api/path、Rust は標準の std::path と Tauri の PathResolver(app.path())を使います。どれも文字列だけを扱い、ファイルが実在するかは確かめません。JS の関数は Node.js の path と名前が同じでも結果が違うものがあるので、その点を中心に説明します。
前提条件
パスを加工するだけならプラグインは不要です。権限 core:path:default は core:default に含まれるので、capability に何も足さずに使えます。組み立てたパスでファイルを読み書きするには、File System プラグイン(npm run tauri add fs)とその権限が別に要ります。
基準になるフォルダーの取り方は アプリ専用のデータ保存フォルダパスを取得する・アプリの設定保存フォルダパスを取得する・一時ファイル用フォルダパスを取得する・ユーザーのホームディレクトリパスを取得する で扱います。
1. フロントエンドから実装する (TypeScript)
主な関数と注意点
sep() と delimiter() 以外は Rust 側で処理されるため Promise を返します。await を忘れると、テンプレート文字列の中で「[object Promise]」がパスに混ざります。
| 関数 | 役割 | 注意する点 |
|---|---|---|
join(...paths) | つないで . と .. を整理 | 途中の絶対パスで置き換わらない |
normalize(path) | .・..・重なった区切りを整理 | 先頭の戻れない .. は消える(Node.js では残る) |
resolve(...paths) | 絶対パスにする | 基準はカレントディレクトリ。途中の絶対パスからやり直す |
dirname() / basename(path, ext?) | 親フォルダー / 最後の要素 | ext はドットを含めて渡す |
extname(path) | 拡張子(ドットなし) | 無いと例外(Node.js は空文字列) |
isAbsolute(path) | 絶対パスか | Windows ではドライブ名の無い /data は false |
import { appDataDir, basename, dirname, extname, join, normalize, resolve, sep } from '@tauri-apps/api/path';
const base = await appDataDir(); // 例: C:\Users\me\AppData\Roaming\com.example.app
console.log(await join(base, 'notes', '2026', 'memo.md')); // ...\com.example.app\notes\2026\memo.md
console.log(await join('notes', '..', 'backup', './a.txt')); // backup\a.txt
console.log(await join('data', '/tmp', 'x.txt')); // data\tmp\x.txt(/tmp で置き換わらない)
console.log(await normalize('docs/./old/../memo.txt')); // docs\memo.txt
console.log(await normalize('../memo.txt')); // memo.txt
console.log(await resolve('logs', 'app.log')); // <カレントディレクトリ>\logs\app.log
console.log(await dirname('/path/to/report.pdf')); // /path/to
console.log(await basename('/path/to/report.pdf', '.pdf')); // report('pdf' だと report. になる)
console.log(await extname('archive.tar.gz')); // gz(最後の 1 つだけ)
console.log(sep()); // \(macOS / Linux は /)
入力でフォルダーの外に出ない
join() は .. を整理するので、ユーザーが入力した ../../ はそのまま基準フォルダーの外を指すパスになります。整理後の文字列には .. が残らないため、後述する fs プラグインの .. の拒否もすり抜けます。入力を足したら、結果が基準フォルダーの中にあるかを確かめます。
import { appDataDir, isAbsolute, join, sep } from '@tauri-apps/api/path';
// base の中を指すパスだけを返す。外に出る入力や絶対パスは例外にする
export async function pathInside(base: string, input: string): Promise<string> {
if (!input.trim() || (await isAbsolute(input))) throw new Error(`invalid path: ${input}`);
const root = await join(base, '.'); // base も同じ形に整理してから比べる
const target = await join(root, input);
if (!target.startsWith(root + sep())) throw new Error(`outside of base: ${input}`);
return target;
}
const dir = await appDataDir();
console.log(await pathInside(dir, 'exports/2026/report.csv'));
await pathInside(dir, '../../secret.txt').catch((e) => console.error(String(e)));
拡張子で分ける・付け替える
extname() は拡張子が無いと失敗します。README やフォルダー名のほか、.gitignore のようにドットで始まるだけの名前も「拡張子なし」です。photo.JPG は JPG のまま返るので、比べる前に小文字にします。
import { basename, dirname, extname, join } from '@tauri-apps/api/path';
// 拡張子を小文字で返す。無ければ空文字列
export async function extOf(path: string): Promise<string> {
return (await extname(path).catch(() => '')).toLowerCase();
}
// 拡張子を付け替える(memo.txt → memo.md)。無ければ付け足す
export async function withExt(path: string, ext: string): Promise<string> {
const raw = await extname(path).catch(() => ''); // 取り除くときは元の大文字小文字のまま使う
const name = await basename(path, raw ? `.${raw}` : undefined);
return join(await dirname(path), `${name}.${ext}`);
}
fs プラグインに渡すとき
fs プラグインには、join() で作った絶対パスより「基準フォルダーからの相対パス + baseDir」を渡す方が、capability に書く $APPDATA などの変数と対応が見えやすくなります。fs プラグインは .. を含むパスを受け付けません。また baseDir を指定しても、パスが絶対パスなら baseDir は無視されます。削除と移動での使い方は ファイルやディレクトリを削除する と ファイル名を変更する・別の場所へ移動する を参照してください。
2. バックエンドから実装する (Rust)
Rust では std::path::Path / PathBuf を使います。JS の join() と違い、Path::join() は引数が絶対パスだとそのパスに置き換わり、.. も整理しません。app.path().resolve(相対パス, BaseDirectory::AppData) も同じで、絶対パスを渡すとそのパスが返ります。JS から受け取った文字列は components() で確かめてから足します。app.path().parse("$APPLOG/app.log") のように、capability と同じ変数で始まる文字列から絶対パスを作ることもできます。
拡張子は extension()(ドットなし。無ければ None)、拡張子を除いた名前は file_stem()、付け替えは with_extension() です。絶対パス化には、ファイルを見ない std::path::absolute()(Rust 1.79 以降)と、実在するパスのリンクをたどる std::fs::canonicalize() があります。
use std::path::{Component, Path, PathBuf};
use tauri::path::BaseDirectory;
use tauri::Manager;
/// 相対パスだけを受け付け、アプリデータフォルダーの中の絶対パスにする
fn app_data_path(app: &tauri::AppHandle, rel: &str) -> Result<PathBuf, String> {
let rel_path = Path::new(rel);
// 絶対パス・ドライブ指定・`..` を拒否する(Path::join は絶対パスで置き換わるため)
let plain = rel_path.components().all(|c| matches!(c, Component::Normal(_) | Component::CurDir));
if rel.is_empty() || !plain {
return Err(format!("invalid path: {rel}"));
}
app.path().resolve(rel_path, BaseDirectory::AppData).map_err(|e| e.to_string())
}
#[derive(serde::Serialize)]
struct PathParts {
full: String,
stem: String,
ext: Option<String>, // 拡張子が無ければ null(JS の extname と違って失敗しない)
backup: String, // 拡張子を .bak に替えたパス
}
#[tauri::command]
fn describe_path(app: tauri::AppHandle, rel: String) -> Result<PathParts, String> {
let path = app_data_path(&app, &rel)?;
let text = |s: Option<&std::ffi::OsStr>| s.map(|s| s.to_string_lossy().into_owned());
Ok(PathParts {
full: path.display().to_string(),
stem: text(path.file_stem()).unwrap_or_default(),
ext: text(path.extension()),
backup: path.with_extension("bak").display().to_string(),
})
}
/// 絶対パスにする 2 つの方法を並べて返す
#[tauri::command]
fn to_absolute(path: String) -> Result<(String, Option<String>), String> {
// ファイルを見ずに、カレントディレクトリを基準に絶対パスにする
let abs = std::path::absolute(&path).map_err(|e| e.to_string())?;
// 実在するときだけ、リンクをたどった実体のパスにする(無ければ None)
let real = std::fs::canonicalize(&path).ok();
Ok((abs.display().to_string(), real.map(|p| p.display().to_string())))
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![describe_path, to_absolute])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
import { invoke } from '@tauri-apps/api/core';
type PathParts = { full: string; stem: string; ext: string | null; backup: string };
const parts = await invoke<PathParts>('describe_path', { rel: 'notes/memo.md' });
console.log(parts.stem, parts.ext, parts.backup);
await invoke('describe_path', { rel: '../secret.txt' }).catch((e) => console.error(e));
const [abs, real] = await invoke<[string, string | null]>('to_absolute', { path: 'notes.txt' });
console.log(abs, real); // real はファイルが無ければ null
動作確認
npm run tauri dev で起動し、1 章の最初のコードをボタンなどから実行すると、Windows では DevTools のコンソールに次のように出ます(ユーザー名と identifier は環境によって変わります)。macOS / Linux では区切りが / になります。
C:\Users\me\AppData\Roaming\com.example.app\notes\2026\memo.md
backup\a.txt
data\tmp\x.txt
docs\memo.txt
memo.txt
pathInside() の 2 つ目は「Error: outside of base: ../../secret.txt」、Rust の describe_path は memo md ...\notes\memo.bak と「invalid path: ../secret.txt」を出します。
よくあるエラーと対処法
- 「path does not have an extension」:
extname()に拡張子の無いパス(README、.gitignore、フォルダー)を渡しました。extOf()のようにcatchで空文字列に置き換えます。 - パスに「[object Promise]」が混ざる:
join()などのawait漏れです。sep()とdelimiter()以外はすべてawaitが要ります。 - fs プラグインが「cannot traverse directory, rewrite the path without the use of
../」を含むエラーを返す:..を含むパスを渡しました。..を含む入力はそもそも受け付けないか、pathInside()で外に出ないことを確かめてから渡します。 resolve()の結果が開発中と配布後で違う: 基準のカレントディレクトリが起動のしかたで変わるためです。保存先はappDataDir()などからjoin()で作ります。- Rust で組み立てたパスがベースと関係ない場所を指す:
Path::join()やresolve()に絶対パスが渡っています。components()で確かめてから足します。
OS ごとの違いと注意点
- Windows: 区切りは
\、delimiter()は;です。join()・normalize()・resolve()は/を\に直して返します。std::fs::canonicalize()は\\?\C:\...という拡張形式で返すので、JS 側のパスと文字列で比べても一致しません。std::path::absolute()は..も整理します。 - macOS / Linux: 区切りは
/、delimiter()は:です。\はファイル名に使える普通の文字なので、Windows 形式のパス文字列は分割されません。std::path::absolute()は..を残します。 - 共通: path API は呼ぶたびに Rust とのプロセス間通信(IPC)が起きます。数千件のパスを一度に加工するなら、Rust のコマンドにまとめます。
