Tauri

デスクトップ Rust バリデーション

Vite+ でバンドルされた ReScript + React のレンダラーと、src-tauri/ 配下の小さな Rust クレートを組み合わせて動作する Tauri 2.x デスクトップアプリです。同梱サンプルは「Hello World」止まりではなく、レンダラーと Rust の間のリクエスト/レスポンス全往復を示します: 文字列を返す greet(name) コマンドと、Validation.res (zod または sury) でレンダラー側がパースしてから形を仮定する struct を返す get_info() コマンドの 2 本。Rust の struct と ReScript の record の間の差分は、React の奥深くで起きる TypeError ではなく UI エラーとして浮かび上がります。

デスクトップアプリを出荷するつもりで、Tauri の CSP、snake_case コマンドのアローリスト、そして Validation.res の境界がすでに配線済みのスタックから始めたい場合はこのテンプレートを選んでください。レンダラーは Vite + React や Electron テンプレートと同じく Vite 駆動の React サーフェスなので React の知識はそのまま流用できます。新しく学ぶ部分は Rust プロセスと tauri::generate_handler! アローリストです。

生成内容

my-project/
├── rescript.json                  # JSX enabled, depends on @rescript/react + @rescript-tauri/core
├── package.json                   # type: "module", @tauri-apps/cli + vite-plus dev deps
├── index.html                     # renderer entry — loads /src/Main.res.mjs as a module
├── vite.config.mjs                # vite-plus + @vitejs/plugin-react, base: "./", strictPort 5173
├── src/
│   ├── Main.res                   # ReactDOM.Client.createRoot(rootEl) → <App />
│   ├── App.res                    # Buttons → greet / get_info → validate → render result/error
│   ├── Tauri.res                  # @rescript-tauri/core bindings (Core.Raw.invoke wrappers)
│   ├── Validation.res             # zod or sury — parses get_info response into typed `info`
│   └── __tests__/App.test.mjs     # vitest smoke test that imports App.res.mjs
├── src-tauri/
│   ├── Cargo.toml                 # tauri + serde + serde_json + tauri-build
│   ├── build.rs                   # tauri_build::build()
│   ├── tauri.conf.json            # CSP, devUrl, frontendDist, beforeDev/Build (PM-aware)
│   ├── .gitignore                 # target/ and gen/
│   └── src/main.rs                # greet / get_info commands + invoke_handler allowlist
├── README.md                      # IPC security model + Production Bundling section
├── LICENSE                        # MIT, holder = project name
├── .nvmrc                         # Node 24
├── .gitignore                     # node_modules + ReScript output + dist/ + src-tauri/target/
├── .editorconfig                  # 2-space indent, LF line endings
└── .github/
    ├── dependabot.yml             # weekly npm updates
    └── workflows/ci.yml           # install + rescript build + vp build + vitest

ウィザードオプション

オプション

効果

Project name

npm の name、Cargo クレートの name、Tauri の productName + identifier + ウィンドウタイトル、そして LICENSE のホルダーになります

Package manager

npm / pnpm / yarn / bun。packageManager フィールド、README のインストール/実行スニペット、CI のキャッシュキー、さらに tauri.conf.json の beforeDevCommand / beforeBuildCommand 文字列に反映されます

Validation library

zod ↔ sury。同梱する src/Validation.res のバリアントと、dependencies に追加される zod / sury のいずれかを選択する

主要な依存

パッケージ

用途

バージョン

rescript

ReScript コンパイラ

TemplateVersions.RESCRIPT

@rescript/core

標準ライブラリ

TemplateVersions.RESCRIPT_CORE

@rescript/runtime

コンパイル済み .res.mjs がインポートするランタイムスタブ

TemplateVersions.RESCRIPT_RUNTIME

@rescript/react

React バインディング (JSX 有効化された rescript.json)

TemplateVersions.RESCRIPT_REACT

@rescript-tauri/core

@tauri-apps/api の ReScript バインディング (Core.Raw.invoke、Event、Window、…)

TemplateVersions.RESCRIPT_TAURI_CORE

@tauri-apps/api

Tauri の JS SDK (@rescript-tauri/core の peer)

TemplateVersions.TAURI_APPS_API

react / react-dom

レンダラーの UI ライブラリ

TemplateVersions.REACT / REACT_DOM

zod または sury

バリデーションバックエンド (IPC ペイロードパーサー)

TemplateVersions.ZOD / SURY

@tauri-apps/cli (dev)

tauri dev / tauri build を駆動するドライバ

TemplateVersions.TAURI_APPS_CLI

vite-plus (dev)

レンダラー用に使われる Vite ベースのバンドラ

TemplateVersions.VITE_PLUS

@voidzero-dev/vite-plus-core (dev)

Vite+ コアランタイム (vite-plus の peer)

TemplateVersions.VITE_PLUS_CORE

vite (dev)

@vitejs/plugin-react が使用する vite の直接ピン

TemplateVersions.VITE

@vitejs/plugin-react (dev)

React refresh + JSX 変換プラグイン

TemplateVersions.VITEJS_PLUGIN_REACT

vitest (dev)

スモークテストランナー

TemplateVersions.VITEST

@vitest/coverage-v8 (dev)

test:coverage のカバレッジプロバイダ

TemplateVersions.VITEST_COVERAGE_V8

Rust の依存 (src-tauri/Cargo.toml) は tauri = "2"、tauri-build = "2"、serde、serde_json のみを pin しています — コマンドサーフェスに応じて随時追加してください。

tauri-plugin-* クレートはデフォルトでは含まれていません。必要になった時点で対応する ReScript バインディング (@rescript-tauri/plugin-dialog、@rescript-tauri/plugin-fs など、@rescript-tauri/core と並んで公開されています) と Rust プラグインクレートを合わせて追加してください。

主要なファイル

src-tauri/src/main.rs

Tauri のメインプロセス。2 つの #[tauri::command] 関数を定義します:

  • greet(name: &str) -> String — 「Say hello」ボタン用のプレーンな RPC

  • get_info() -> AppInfo — serde::Serialize でシリアライズされた { name, version, platform, arch } を返します

invoke_handler マクロが両方を IPC アローリストに登録します:

.invoke_handler(tauri::generate_handler![greet, get_info])

新しいコマンドを定義してこのリストを更新しないと、JS 側で静かに 404 になります。コマンド名は Rust 関数名の snake_case のままで、Core.Raw.invoke が JS から呼ぶ名前と完全に一致します。

src-tauri/tauri.conf.json

Tauri 2.x の設定ファイル。主なフィールド:

  • build.beforeDevCommand と build.beforeBuildCommand — スキャフォールド時にパッケージマネージャ固有の dev / build 起動 (pnpm dev / npm run dev / yarn dev / bun run dev) で置換されます。tauri dev はウィンドウを開く前にレンダラーの dev サーバを起動し、tauri build は先に vp build を走らせて dist/ を生成します。

  • build.devUrl: "http://localhost:5173" — vite.config.mjs の strictPort: true と対になっています。別ポートが必要な場合は両方を一緒に変更してください。

  • build.frontendDist: "../dist" — tauri build 時に Tauri がこのパスから Vite+ の出力を読み込みます。

  • app.security.csp — default-src 'self' に加えて、asset: / https://asset.localhost (Core.Raw.convertFileSrc 用) と ipc: / http://ipc.localhost (Windows / macOS で Tauri の invoke トランスポート用) を許可しています。本番アプリでは明示的な CSP を必ず定義してください — Tauri の CSP ガイド を参照。

  • bundle.icon — 意図的に省略しています。アイコンソースなしのチェックアウトでも tauri dev が動作するためです。tauri build を実行する準備ができたら tauri icon path/to/source.png でアイコンを生成し、出来上がったリストを書き戻してください。

src/Tauri.res

@rescript-tauri/core の薄い ReScript バインディング。同梱のラッパーは get_info を意図的に JSON.t 型として定義しています — 最終的な info レコードではありません。バリデーションは 1 つ深いレイヤーで行うためです:

let greet = (name: string): promise<string> =>
  RescriptTauriCore.Core.Raw.invoke("greet", ~args={"name": name})

let getInfoRaw = (): promise<JSON.t> =>
  RescriptTauriCore.Core.Raw.invoke("get_info", ~args=Js.Obj.empty())

getInfoRaw の Raw サフィックスは意図的な命名規約です: 「この値はまだバリデーションされていない」と示すことで、呼び出し側に分解前に Validation.res を通す必要があると伝えます。

src/Validation.res

parseInfo: JSON.t => result<info, string>。シグネチャは zod バリアントと sury バリアントで同一であるため、レンダラーの呼び出し側はどちらのライブラリが同梱されたかで分岐しない:

type info = {name: string, version: string, platform: string, arch: string}
let parseInfo: JSON.t => result<info, string>

なぜバリデーションするのか? レンダラーは Tauri のアローリストが未知のコマンドを拒否することは信頼できますが、Rust から戻ってくるstruct の形は証明されるまでただの JSON です。Rust 側で AppInfo にフィールドを足して parseInfo への追加を忘れたら? React が undefined を見る前にバリデータがパースエラーとして検出します。

src/App.res

インタラクティブなデモ。2 つのボタンを備えます:

  • Say hello は Tauri.greet("ReScript") を実行し、返ってきた文字列を表示します。

  • Get app info は Tauri.getInfoRaw() を実行し、結果をバリデーションした上で、型付きレコードの <dl> か、赤い IPC validation error: ... バナーのどちらかをレンダリングします。

main.rs の get_info を書き換えて想定外のペイロード — フィールドの削除、名前変更、型変更 — を返すようにすると、React ツリーがクラッシュすることなくバリデータがそれを捕捉する様子を確認できます。

src/Main.res

index.html の <script type="module"> が読み込むレンダラーエントリ。<App /> を #root にマウントします:

switch ReactDOM.querySelector("#root") {
| Some(rootEl) =>
  ReactDOM.Client.Root.render(ReactDOM.Client.createRoot(rootEl), <App />)
| None => Console.error("Could not find root element")
}

vite.config.mjs

最小限の Vite+ 設定 — vite ではなく vite-plus から defineConfig、React プラグイン、base: "./"、server.strictPort: true、server.port: 5173、clearScreen: false。相対 base は Tauri が file:// 経由で dist/index.html をロードしても本番バンドルが動くように保ちます。strictPort は 5173 番が埋まっていたときに Vite を即座に失敗させます — 黙って 5174 番にスライドすると tauri.conf.json の devUrl が誤った場所を指したままになるためです。

npm スクリプト

スクリプト

説明

tauri:dev

tauri dev — フルアプリ: (beforeDevCommand 経由で) レンダラーの dev サーバを起動し、Tauri ウィンドウを開きます

tauri:build

tauri build — 本番バイナリ。beforeBuildCommand でレンダラーをバンドルしてから生成します (Rust + プラットフォーム依存が必要)

tauri

tauri — tauri icon / tauri info / tauri signer などの passthrough

dev

vp dev — Vite+ dev サーバのみ (Tauri を起動せずブラウザで UI を反復したいときに利用)

build

vp build — レンダラーを dist/ にバンドルする

test

vp test — Vite+ ランナー上で Vitest を実行

test:coverage

vp test --coverage — 同上、v8 カバレッジ付き

res:build

rescript — ワンショットの ReScript コンパイル

res:dev

rescript -w — 保存時に再コンパイル

res:clean

rescript clean — 生成された .res.mjs を削除

新しい Tauri コマンドを追加する

コマンド追加は常に 3 つのレイヤーに触れます — README に手順を載せているのと併せて、形を覚えておく価値があります。どこかを忘れると黙って失敗するためです (レンダラー側で "command set_window_title not found" になります)。

  1. src-tauri/src/main.rs でコマンドを定義する:

    #[tauri::command]
    fn set_window_title(window: tauri::Window, title: String) -> Result<(), String> {
        window.set_title(&title).map_err(|e| e.to_string())
    }
    
  2. invoke_handler に登録する (引き続き main.rs 内で):

    .invoke_handler(tauri::generate_handler![greet, get_info, set_window_title])
    
  3. src/Tauri.res でバインドする:

    let setWindowTitle = (title: string): promise<unit> =>
      RescriptTauriCore.Core.Raw.invoke(
        "set_window_title",
        ~args={"title": title},
      )
    
  4. ハンドラが UI で消費するデータを返すなら、Validation.res でレスポンスをバリデーションします。fire-and-forget なコマンド (set_window_title は () を返す) はこのステップを省略できます。

本番バンドル

tauri build には Rust ツールチェイン (rustup default stable) と、プラットフォーム固有の依存が必要です — Tauri 2 の前提条件ガイド を参照してください。Linux は webkit2gtk + libsoup、Windows は MSVC C++ ビルドツール、macOS は Xcode CLI ツールが必要です。

初回の tauri build の前にアイコンセットを生成します:

tauri icon path/to/source.png
# Writes 32x32.png / 128x128.png / icon.icns / icon.ico into src-tauri/icons/

生成されたファイル名で tauri.conf.json の bundle.icon を再度有効化してください。署名と自動更新はスターターの範囲外です — レシピへのポインタ (macOS の Developer ID notarisation、Windows の signtool、Tauri updater プラグイン) は README の "Production Bundling" セクションを参照してください。

このテンプレートでは CI で tauri build を走らせていません — OS ごとのランナーと署名情報が必要になり、プロジェクトごとに大きく異なるためです。配布戦略が固まったら自分でワークフローを追加してください。

2日目以降のレシピ

プロジェクトを開いた後の ReScript 側のエディタワークフローについては 機能概要 を参照のこと。

補足

  • Tauri コマンドは snake_case を使います。 Rust の関数名は JS の呼び出し名にそのままマップされます — greet、get_info、set_window_title。マクロは camelCase に自動変換しません。

  • invoke_handler がアローリストです。 関数に #[tauri::command] を付けるだけでは何も起きません — generate_handler![...] にも追加する必要があります。これを忘れると黙って失敗します (JS の呼び出しが "command X not found" で reject されます)。

  • レンダラーは @tauri-apps/api を直接 import しません。 すべての プロセス間呼び出しは Tauri.res → RescriptTauriCore.Core.Raw.invoke(...) → #[tauri::command] を通ります。import { invoke } from "@tauri-apps/api" を書きたくなったときは、ほぼ確実に新しい ReScript バインディングを足すべきです。

  • 関数、クラスインスタンス、DOM ノードはブリッジを越えられません。 Tauri は引数と戻り値を JSON としてシリアライズするので、プリミティブ、owned な文字列、プレーンな struct (serde::Serialize)、配列、マップだけが対象です。

  • CSP の connect-src ホワイトリストは fetch/XHR を制御するもので、invoke ではありません。 Tauri の IPC は専用の ipc: と http://ipc.localhost オリジンを使い、どちらも同梱の CSP に含まれています。削除しないでください。

  • @rescript-tauri/core は pre-1.0 (0.1.x) です。 1.0 が出るまでリリース間で軽い破壊的変更が入る可能性があります。バージョン pin は ^0.1.x なので、破壊的な 0.minor 更新を自動採用することなく patch 更新だけが流入します。

  • vite-plus も pre-1.0 です。 バージョン pin は ^0.1.x。リリースがバンドラのワークフローを壊した場合に備えて、クラシック Vite へのスワップ手順を README に記載しています。

  • スモークテストは意図的に小さくしています。 App.test.mjs は import("../App.res.mjs") が resolve することだけを確認します — Tauri を起動したり IPC を動かしたりはしません。レンダラーが成長するにつれてドメイン固有のテストを追加してください。