Use Cases (更新: 2026/6/2)

Claude CodeでTauri開発:Rustコマンドと権限設計までわかるデスクトップアプリ入門

Claude CodeでTauri v2デスクトップアプリを作る実務ガイド。Vite、Rustコマンド、権限設計、ビルド確認まで解説。

Claude CodeでTauri開発:Rustコマンドと権限設計までわかるデスクトップアプリ入門

Tauriは、Webの画面をWindows、macOS、Linuxのデスクトップアプリとして配布するためのフレームワークです。画面はReactやSvelteなどで作り、OSに近い処理はRustで書きます。Rustはコンパイル時に多くのミスを止めてくれる言語で、Tauriでは「ファイルを読む」「ローカルDBを扱う」「OSのダイアログを開く」といった処理の足場になります。

Claude Codeと相性が良い理由は、Tauriの開発が「フロントエンド」「Rustコマンド」「権限」「ビルド」の4層に分かれるからです。Claude Codeに一度に全部を任せるより、1層ずつ小さく依頼すると、レビューしやすく、危ない権限追加も見つけやすくなります。

この記事では、Tauri v2で小さなメモアプリを作る前提で、Vite + ReactまたはSvelteのセットアップ、Rustコマンド、フロントエンドからのinvoke、ファイルシステム権限、ビルドとテスト、Claude Codeへ投げるレビュー用プロンプトまでまとめます。公式情報はTauri v2のCreate a ProjectCalling Rust from the FrontendCapabilitiesFile System pluginVite Getting StartedCargo testClaude Code setupを基準にしています。

関連して、Rust側の基礎を先に固めたい人はClaude CodeでRust開発入門を、Electronと比較したい人はClaude CodeでElectronデスクトップアプリ開発を読むと判断しやすくなります。

Tauriを選ぶ場面

Tauriを選ぶべきなのは、「Web UIの開発体験を使いたいが、配布物はローカルアプリにしたい」場面です。たとえば、顧客データをブラウザにアップロードしたくない社内ツール、ローカルファイルを扱う変換ツール、ネットワークが弱い現場で使う入力アプリなどです。

一方で、すべてのWebアプリをTauriにする必要はありません。ログイン中心で、常にサーバーAPIへ接続し、OS機能をほとんど使わないなら、普通のWebアプリやPWAで十分なことがあります。Tauriを選ぶと、署名、インストーラー、更新、OS差分、Rustのビルド環境まで面倒を見る必要があります。軽いから選ぶのではなく、「ローカル機能を安全に持ちたいから選ぶ」と考える方が失敗しにくいです。

Claude Codeに最初に頼むなら、次のように境界を明確にします。

Tauri v2で小さなメモアプリを作ります。
まず実装せず、React UI、Rust command、capability、build/testの4層に分けた作業計画を出してください。
ファイルシステム権限はapp data配下だけに限定し、任意パスの読み書きは提案しないでください。

この依頼により、Claude Codeがいきなり広いfs権限や任意パスの読み書きを入れるリスクを下げられます。

全体像

Tauriでは、ブラウザ画面のJavaScriptがOSを直接触るのではなく、Rust側に定義したコマンドを呼び出します。ここがセキュリティの中心です。

flowchart LR
  UI["React or Svelte UI"] --> Invoke["invoke from @tauri-apps/api/core"]
  Invoke --> Command["Rust command"]
  Command --> Guard["path allowlist and validation"]
  Guard --> AppData["app data directory"]
  Command --> Result["typed result"]
  Result --> UI
  Capability["Tauri capability"] --> UI

大事なのは、TauriのcapabilityだけでRustコマンドの中身が自動的に安全になるわけではない点です。capabilityはフロントエンドから使えるTauri APIやプラグイン権限を制限します。自分で書いたRustコマンドは、アプリのプロセス権限で動きます。つまり、Rust側にも「どのディレクトリだけ許可するか」「..で外へ出られないか」「書き込み前に親ディレクトリを確認するか」を実装する必要があります。

ReactまたはSvelteで始める

新規プロジェクトなら、Tauri公式のcreate-tauri-appを使うのが一番安全です。ReactでもSvelteでも、TypeScriptを選んでおくと、invokeの戻り値やUI状態をレビューしやすくなります。

npm create tauri-app@latest taskdesk
cd taskdesk
npm install
npm run tauri dev

途中の質問では、ReactまたはSvelte、言語はTypeScriptを選びます。既存のViteアプリにTauriを足す場合は、Vite側を作ってからTauri CLIで初期化します。Vite公式ではnpm create vite@latestが案内されており、2026年時点のViteはNode.js 20.19+または22.12+を要求します。Nodeが古いままTauriのエラーに見えるケースがあるので、最初に確認します。

node --version
npm create vite@latest taskdesk -- --template react-ts
cd taskdesk
npm install
npm install -D @tauri-apps/cli@latest
npm install @tauri-apps/api@latest
npx tauri init
npx tauri dev

Svelteで始めるならテンプレートだけ変えます。

npm create vite@latest taskdesk -- --template svelte-ts

Claude Codeには、セットアップ直後に「依存関係を足す」より先に、生成されたsrc-tauripackage.jsontauri.conf.jsonを読ませます。TauriはテンプレートやCLIの更新でファイル構成が変わるため、古い記事の固定構成を前提にさせないことが重要です。

Tauri設定を小さく保つ

tauri.conf.jsonは、フロントエンドの開発URL、ビルド成果物、ウィンドウ、capability参照をつなぎます。以下はVite前提の最小例です。実際のプロジェクトでは既存ファイルを上書きせず、差分として確認してください。

{
  "$schema": "https://schema.tauri.app/config/2",
  "productName": "TaskDesk",
  "version": "0.1.0",
  "identifier": "com.example.taskdesk",
  "build": {
    "beforeDevCommand": "npm run dev",
    "devUrl": "http://localhost:5173",
    "beforeBuildCommand": "npm run build",
    "frontendDist": "../dist"
  },
  "app": {
    "windows": [
      {
        "title": "TaskDesk",
        "width": 1000,
        "height": 700
      }
    ],
    "security": {
      "capabilities": ["main-capability"]
    }
  },
  "bundle": {
    "active": true,
    "targets": "all"
  }
}

Claude Codeに設定を触らせるときは、「identifierを仮のままにしない」「devUrlとViteのポートを合わせる」「frontendDistdistを指しているか確認する」の3点を必ずレビューします。

Rustコマンドを作る

次は、メモファイルの読み書きと一覧取得をRustコマンドにします。ここでは任意パスを受け取らず、アプリのデータディレクトリ配下だけを許可します。Component::ParentDir、絶対パス、Windowsのプレフィックスを拒否し、既存ファイルはcanonicalizeで実体パスを確認します。

// src-tauri/src/note_commands.rs
use serde::Serialize;
use std::{
    fs,
    path::{Component, Path, PathBuf},
};
use tauri::{AppHandle, Manager};

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NoteFile {
    name: String,
    path: String,
    bytes: u64,
    is_dir: bool,
}

fn reject_unsafe_relative(path: &Path) -> Result<(), String> {
    for component in path.components() {
        match component {
            Component::Normal(_) | Component::CurDir => {}
            _ => return Err("use a relative path inside app data".to_string()),
        }
    }
    Ok(())
}

fn app_data_root(app: &AppHandle) -> Result<PathBuf, String> {
    let root = app
        .path()
        .app_data_dir()
        .map_err(|error| format!("failed to get app data dir: {error}"))?;
    fs::create_dir_all(&root).map_err(|error| format!("failed to create app data dir: {error}"))?;
    root.canonicalize()
        .map_err(|error| format!("failed to resolve app data dir: {error}"))
}

fn existing_path(app: &AppHandle, relative: &str) -> Result<PathBuf, String> {
    let root = app_data_root(app)?;
    let requested = Path::new(relative);
    reject_unsafe_relative(requested)?;
    let full = root
        .join(requested)
        .canonicalize()
        .map_err(|error| format!("path does not exist: {error}"))?;

    if !full.starts_with(&root) {
        return Err("path escapes app data".to_string());
    }

    Ok(full)
}

fn writable_path(app: &AppHandle, relative: &str) -> Result<PathBuf, String> {
    let root = app_data_root(app)?;
    let requested = Path::new(relative);
    reject_unsafe_relative(requested)?;
    let full = root.join(requested);
    let parent = full.parent().ok_or("missing parent directory")?;
    fs::create_dir_all(parent).map_err(|error| format!("failed to create parent dir: {error}"))?;
    let parent = parent
        .canonicalize()
        .map_err(|error| format!("failed to resolve parent dir: {error}"))?;

    if !parent.starts_with(&root) {
        return Err("path escapes app data".to_string());
    }

    Ok(full)
}

#[tauri::command]
pub fn read_note(app: AppHandle, path: String) -> Result<String, String> {
    let safe_path = existing_path(&app, &path)?;
    fs::read_to_string(safe_path).map_err(|error| format!("failed to read note: {error}"))
}

#[tauri::command]
pub fn write_note(app: AppHandle, path: String, content: String) -> Result<(), String> {
    let safe_path = writable_path(&app, &path)?;
    fs::write(safe_path, content).map_err(|error| format!("failed to write note: {error}"))
}

#[tauri::command]
pub fn list_notes(app: AppHandle, dir: String) -> Result<Vec<NoteFile>, String> {
    let safe_dir = existing_path(&app, &dir)?;
    let mut files = Vec::new();

    for entry in fs::read_dir(safe_dir).map_err(|error| format!("failed to read dir: {error}"))? {
        let entry = entry.map_err(|error| format!("failed to read entry: {error}"))?;
        let metadata = entry
            .metadata()
            .map_err(|error| format!("failed to read metadata: {error}"))?;
        let name = entry.file_name().to_string_lossy().to_string();

        files.push(NoteFile {
            name: name.clone(),
            path: name,
            bytes: metadata.len(),
            is_dir: metadata.is_dir(),
        });
    }

    Ok(files)
}

別モジュールにコマンドを置いた場合は、lib.rsで登録します。Tauri公式ドキュメントでも、別モジュールのコマンドはpubにし、generate_handler!へ渡す流れが示されています。

// src-tauri/src/lib.rs
mod note_commands;

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            note_commands::read_note,
            note_commands::write_note,
            note_commands::list_notes
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

フロントエンドから呼び出す

フロントエンドでは@tauri-apps/api/coreinvokeを使います。Rust側の#[serde(rename_all = "camelCase")]に合わせて、TypeScriptの型もisDirにしています。

// src/lib/notesApi.ts
import { invoke } from "@tauri-apps/api/core";

export type NoteFile = {
  name: string;
  path: string;
  bytes: number;
  isDir: boolean;
};

export const notesApi = {
  read(path: string) {
    return invoke<string>("read_note", { path });
  },
  write(path: string, content: string) {
    return invoke<void>("write_note", { path, content });
  },
  list(dir = ".") {
    return invoke<NoteFile[]>("list_notes", { dir });
  },
};

Reactなら、まずは保存と読み込みだけの小さな画面で十分です。

// src/App.tsx
import { useState } from "react";
import { notesApi } from "./lib/notesApi";

export default function App() {
  const [content, setContent] = useState("");
  const [message, setMessage] = useState("Ready");
  const fileName = "daily-note.txt";

  async function loadNote() {
    try {
      setContent(await notesApi.read(fileName));
      setMessage("Loaded");
    } catch (error) {
      setMessage(String(error));
    }
  }

  async function saveNote() {
    try {
      await notesApi.write(fileName, content);
      setMessage("Saved");
    } catch (error) {
      setMessage(String(error));
    }
  }

  return (
    <main>
      <h1>TaskDesk</h1>
      <textarea value={content} onChange={(event) => setContent(event.target.value)} />
      <button onClick={loadNote}>Load</button>
      <button onClick={saveNote}>Save</button>
      <p>{message}</p>
    </main>
  );
}

Svelteなら同じAPIをそのまま呼べます。

<!-- src/App.svelte -->
<script lang="ts">
  import { notesApi } from "./lib/notesApi";

  let content = "";
  let message = "Ready";
  const fileName = "daily-note.txt";

  async function loadNote() {
    try {
      content = await notesApi.read(fileName);
      message = "Loaded";
    } catch (error) {
      message = String(error);
    }
  }

  async function saveNote() {
    try {
      await notesApi.write(fileName, content);
      message = "Saved";
    } catch (error) {
      message = String(error);
    }
  }
</script>

<main>
  <h1>TaskDesk</h1>
  <textarea bind:value={content}></textarea>
  <button on:click={loadNote}>Load</button>
  <button on:click={saveNote}>Save</button>
  <p>{message}</p>
</main>

ファイル権限とcapability

Tauri v2のcapabilityは、どのウィンドウやWebViewにどの権限を与えるかを定義します。プラグインのFile System APIをフロントエンドから使うなら、src-tauri/capabilities配下で許可範囲を明示します。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "main-capability",
  "description": "Main window permissions for TaskDesk.",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "fs:default",
    "fs:allow-app-read-recursive",
    "fs:allow-app-write-recursive"
  ]
}

この記事のサンプルはRustコマンドでファイル操作をしています。したがって、上のfs権限は「フロントエンドがFile System pluginを直接使う場合」の考え方として示しています。Rustコマンドだけで読むなら、capabilityにfs:allow-download-read-recursivefs:scope-document-recursiveを広く足す必要はありません。

Claude Codeに権限を足させる時は、次のレビューを必ず挟みます。

変更せずにレビューだけしてください。
対象はsrc-tauri/capabilities配下とsrc-tauri/src/note_commands.rsです。
1. フロントエンドから呼べるTauri APIとRust commandを列挙する
2. 各APIが触れるファイルパスの範囲を説明する
3. app data外、絶対パス、親ディレクトリ参照、広すぎるwildcardを重大度つきで指摘する
4. 権限を減らせる具体案を出す

このプロンプトは、Claude Codeを実装者ではなく監査役として使うためのものです。自分で書かせた直後に同じ流れでレビューさせると甘くなりやすいので、できれば別セッションで差分だけ読ませます。

ビルドとテスト

最低限の確認コマンドは、フロントエンド、Rust、Tauriの3つに分けます。

npm run build
cd src-tauri
cargo test
cd ..
npm run tauri build

npm run buildはViteの本番ビルドです。cargo testはRust側の単体テストと統合テストを動かします。npm run tauri buildはOS向けの配布物を作る段階なので、最初から毎回通すより、RustコマンドとViteビルドが通ってから実行する方が原因を切り分けやすいです。

パス検証のテストを足すなら、Rustの純粋関数としてreject_unsafe_relativeをテストします。AppHandleが絡む部分を無理に単体テストに押し込むより、危険な入力を拒否する小さい関数を先に固めるのが現実的です。

#[cfg(test)]
mod tests {
    use super::reject_unsafe_relative;
    use std::path::Path;

    #[test]
    fn rejects_parent_directory() {
        assert!(reject_unsafe_relative(Path::new("../secret.txt")).is_err());
    }

    #[test]
    fn accepts_simple_relative_path() {
        assert!(reject_unsafe_relative(Path::new("notes/today.txt")).is_ok());
    }
}

Claude Codeへの修正依頼も、ビルド全体ではなく失敗している層を指定します。

`cargo test`だけが落ちています。
src-tauri/src/note_commands.rsのパス検証テストを読んで、最小差分で直してください。
capability、React UI、tauri.conf.jsonは変更しないでください。
修正後に実行すべき確認コマンドも最後に列挙してください。

実例とユースケース

1つ目は、個人用メモや日報アプリです。本文はローカルのapp dataに保存し、同期は後から追加します。初心者がTauriを学ぶ題材として扱いやすく、Rustコマンド、invoke、権限、ビルドの全部を小さく試せます。

2つ目は、CSVやMarkdownの変換ツールです。社内の手元ファイルを読み、テンプレートに沿って整形し、結果を書き出します。ブラウザにファイルをアップロードしないため、顧客情報や下書き資料を扱う現場で説明しやすい構成です。

3つ目は、開発者向けログビューアです。ローカルのログを読み、検索、フィルタ、エクスポートを行います。TauriならWeb UIで一覧性を作りつつ、Rust側で大きなファイルの処理やパス制限を実装できます。

4つ目は、オフライン入力アプリです。イベント受付、工場の点検、店舗の棚卸しなど、ネットワークが不安定な場所では、まずローカルに保存して後で同期する設計が便利です。ただし同期や競合解決は難しいので、最初からTauriだけで全部を解決しようとしない方が安全です。

よくある落とし穴

一番多い失敗は、File System pluginのcapabilityと自作Rustコマンドの安全性を混同することです。fs権限を狭くしても、自作コマンドが任意パスを受け取れば危険です。Rust側で必ず検証します。

次に、Claude Codeへ「Tauriアプリを完成させて」と広く頼むことです。UI、権限、保存形式、更新、配布まで一気に変わるとレビュー不能になります。「コマンド1つ」「capability 1つ」「テスト1つ」に分けます。

3つ目は、開発中だけ動く設定を本番に持ち込むことです。devUrl、ビルド出力、署名、アイコン、アプリIDは、後で直すつもりでも忘れやすい項目です。公開前にはtauri.conf.jsonを必ず読み直します。

4つ目は、OS差分を軽く見ることです。Windows、macOS、LinuxではWebView、ファイルパス、署名、インストーラーの挙動が違います。少なくとも配布対象OSで起動確認し、ファイル保存先、文字コード、権限エラー表示を確認します。

Claude Codeに任せる範囲

Claude Codeには、反復しやすい作業を任せると効果が出ます。たとえば、invokeの型をそろえる、RustのResultエラーを読みやすくする、capabilityの差分を説明させる、テストケースを追加する、ビルドエラーを切り分ける、といった作業です。

逆に、人間が決めるべきなのは「どのファイルへアクセスしてよいか」「ユーザーにどの警告を見せるか」「配布対象OSは何か」「自動更新を入れるか」「データをローカルに残す期間はどれくらいか」です。これらはコードの正しさだけでなく、プロダクト責任や運用責任に関わります。

チームで導入するなら、権限監査チェックリストのようなレビュー観点をCLAUDE.mdに短くまとめると、毎回の依頼が安定します。個人開発でも、「触ってよいファイル」「変更してはいけない設定」「確認コマンド」を書くだけで、差分の質はかなり変わります。

収益導線と次の一歩

Tauri記事の読者は、単なる情報収集ではなく「自分のローカルツールを作りたい」「社内配布したい」という実装意図を持っていることが多いです。ClaudeCodeLabでは、こうした実装を再現しやすくするために教材一覧Claude Code研修・相談を用意しています。個人ならプロンプトテンプレートでレビュー観点を固定し、チームなら実リポジトリを題材に権限、CLAUDE.md、確認コマンド、配布前チェックを整えるのが現実的です。

この記事で紹介した内容を実際に試した結果、作業が一番安定したのは「先にRustコマンドのパス制限を作り、次にTypeScriptのinvoke型を合わせ、最後にcapabilityを確認する」順番でした。逆に、UIから先に作ると、保存先や権限の後付けで差分が大きくなりました。Tauri + Claude Codeでは、見た目より先に境界を決めることが、完成速度にも安全性にも効きます。

#Claude Code #Tauri #Rust #デスクトップアプリ #フロントエンド
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。