📺

[TSKaigi2025] TypeScript だけを書いて Tauri でデスクトップアプリを作ろう (記事版)

に公開

2025/05/23・24 に行われた TSKaigi2025 で、「TypeScript だけを書いて Tauri でデスクトップアプリを作ろう」という LT を行いました。5分間に収まりきらなかった部分があるので、内容を追加して記事としてまとめます。

スライド

まとめ

  • TypeScript だけで充分に実用的なネイティブアプリを作れる。
    • ネイティブアプリとして必要な処理を TypeScript から簡単に呼び出せる。
  • ツラみはあるが、快適さが勝るのでかなりオススメ。
    • Rust を知らなくても良い。
    • Web 系の知識を活かして、その延長でアプリが作れる。
    • 開発ループが速い。

Tauri とは


Tauri は Rust 製のクロスプラットフォームフレームワークです。デスクトップ(Windows, macOS, Linux)に加えて、モバイル(Android, iOS)のネイティブアプリも作成可能です。

アプリの基本的な部分は主に Rust(環境によって Swift や Kotlin)のネイティブ言語で書かれたコードで動作し、UI の部分を JavaScript/TypeScript によって記述して Web ビューに描画する仕組みになっています。(UI 部分に Rust の Web フロントエンド向けクレートも利用可能です)

UI の描画のために、システムが備えている Web ビューを使用するのが Tauri の特徴です。そのためアプリ自体には Web ブラウザをバンドルする必要がなく、アプリのファイルサイズが小さくなります。例えば macOS 向けだと 10MB 程度のレベル感になります。

UI 部分で使用する Web フロントエンド技術を選びません。JS/TS で動けば良いだけなので、React・Vue・Svelte・Solid のような最近のものも使えますし、古くからある jQuery や、果ては単なる Vanilla JavaScript でも書けます。なお Next.js のようなメタフレームワークも利用できますが、Static Export のみ対応しているなどの制限があります(参考)。

Rust 等で書かれたネイティブ処理と、Web ビューで動く JS/TS の間は、プロセス間通信(IPC)によりやり取りを行います。基本的には Rust 側で処理を行い、その結果を UI 側に伝えて画面を描画する、という構造になっています。つまり、Rust で書かれたアプリケーションに、Web ビューを使用した UI を被せるという思想のフレームワークです。

TypeScript だけで Tauri を使う

「Rust はちょっと……」と腰が引ける人は多いと思います。実際、Rust は難しくて大変です。

そんな人におすすめなのが、TypeScript だけで Tauri を使うやり方です。Rust をまったく触ることなく、充分な機能を備えたアプリを開発できます。

Tauri では JS/TS からバックエンド側 (Rust) の処理を呼び出す API やプラグインが提供されています。これを使えば、TS から簡単にネイティブ側の処理を実行可能です。例えばファイル操作ウィンドウ操作メニューネットワークアクセス通知といった、ネイティブアプリとして必要な機能が公式から提供されています。

呼び出しの詳細については後述します。

TypeScript だけでどんなものが作れるか?

かなりのものが作れます。普通のアプリは問題なく作れますし、アプリ外でのキー操作を取得して動作するランチャーのようなものも作成可能です。

例として、自分は macOS 向けの漫画ビューワである magv というものを作っています。

https://github.com/tris5572/magv

これは以下のような、普通の漫画ビューワが備えているような機能を実現しています。

  • フォルダ内の画像を開く
  • 画像をまとめた zip ファイルを開く
  • 前後の zip ファイルを開く
  • zip ファイルをリネームする
  • ウィンドウ位置を保存・復元する

JS/TS のエコシステムが利用できますし、ネイティブ処理を TS から呼び出すことで普通のアプリを作れています。

ただ TS だけではちょっとした限界があったりはします。例えばアプリ起動時のウィンドウ位置の復元が一発で決まらない(一瞬別の位置で表示されたあとに復元する)という挙動になっており、これを解消するためには起動時のパラメーター設定を Rust で書き換える実装が必要そうです(多分)。あと、大きい zip ファイルを開いたときに遅いので、これをなんとかしたいとは思っています。

TypeScript だけで書く嬉しさ


TypeScript だけで書ける

何かの構文のようになっていますが、実際 TS だけで書けるのはかなり嬉しいと思います。

新たに Rust を習得する必要がなく、書きやすく書き慣れた TS だけでデスクトップアプリ作成が完結するのは魅力で夢があります。

HMR で快適に開発可能

Hot Module Replacement、いわゆるホットリロードがよく効いて、快適に開発可能です。コードを変更して保存するとすぐに反映されます。

ネイティブ処理を Rust で書いていると、保存するたびにビルド処理が走り、動作を確認するための数秒の待ち時間が発生します。しかしすべてを TS で書いていると変更が即時反映されるため、かなり良い開発者体験を得ることができます。

逆に UI 部分まで Rust で書いていると、何をするにも待ち時間が発生してしまいかなり体験が損なわるため、雲泥の差です。

使い慣れた Web 系ツールチェインだけで完結

これは TS で書くからというわけではないのですが、Rust ツールチェイン (cargo 等) を基本的に触らなくて良く、pnpm などの Web 系ツールチェインだけを使って開発が完結します。

pnpm create tauri-app でプロジェクトを作成し、pnpm tauri dev で開発実行し、pnpm tauri build で実行ファイルをビルドする、といったことが可能です。

TypeScript だけで書くツラみ

もちろん良い部分だけではなく、少しツラい部分もあります。

ネイティブ処理 (Rust 実装) の呼び出しがちょっと遅い

TS からはプロセス間通信で Rust 実装のネイティブ処理を呼び出すことになりますが、通信が絡むことによるオーバーヘッドにより遅さを感じることがあります。

実際、単純に呼び出し処理を 1,000回 ループ実行すると、完了までにおよそ 100ms 程度を要します。例えばフォルダ内のファイルの情報をループで取得するような場合に引っかかったりします。

この場合の対応としては、ループ処理を Rust 側にまとめて、TS からの呼び出しを1回のみにするという方法が一番効きます。「TS だけで書く」という趣旨からは外れてしまいますが、Rust が書けるならこれが確実です。


ここからは補足として、ネイティブ処理との連携や Tauri 自体のツラみなどについて少し書いていきます。

ネイティブ処理の呼び出し

Tauri のネイティブ処理の呼び出しがどのようなものかを軽く見ていきます。

(公式ドキュメント:Calling Rust from the Frontend

基本的な呼び出し方法

TS から Rust で書かれた処理を呼び出すときは、@tauri-apps/api/coreinvoke 関数によりプロセス間通信を実行します。この関数の型定義 (.d.ts ファイル) を見ると、いくつかのことが分かります。

declare function invoke<T>(cmd: string, args?: InvokeArgs, options?: InvokeOptions): Promise<T>;
  • コマンド名 cmd として文字列を指定して実行する。
  • 引数 args とオプション options を渡せる。
  • 実行結果の戻り値の型 T を指定できる。
  • 非同期関数であり、Promise に包まれて結果が返ってくる。

コマンド名は、基本的には Rust 側でコマンドとして定義した関数の名前です。例えばテンプレートプロジェクトでは、以下のように name という文字列スライスの引数を取る greet 関数が定義されています。

lib.rs
#[tauri::command]
fn greet(name: &str) -> String {
    format!("Hello, {}! You've been greeted from Rust!", name)
}

これを TS 側から invoke("greet", { name: "名前" }) として呼び出すことで、greet の実行結果である加工された文字列を得ることができます。(ちなみにこれだと戻り値の型 T を指定していないので、型推論結果は Promise<unknown> になります)

非同期関数になっているのはプロセス間通信が行われるためで、普通の fetch 等による通信と同じような非同期処理になります。

TypeScript 向け API

Rust の関数は invoke で TS から呼び出すことになりますが、実際に使用する機会は少ないと思われます。Tauri では API やプラグインとしてすでに Rust 実装をラップした TS の実装が用意されており、それだけでほぼ事足りるためです。

例えばパスで指定したファイルのサイズを得るための関数が @tauri-apps/plugin-fssize 関数として存在します。(コード

async function size(path: string | URL): Promise<number> {
  if (path instanceof URL && path.protocol !== 'file:') {
    throw new TypeError('Must be a file URL.')
  }

  return await invoke('plugin:fs|size', {
    path: path instanceof URL ? path.toString() : path
  })
}

引数のチェックを行ったあと、invoke で Rust 実装を関数内部で呼び出してくれています。これにより TS 側からは単なる TS の関数呼び出しとして実行するだけで、ネイティブ側の実装を気にせずに実行結果(ファイルサイズ)を得ることができるようになっています。

こうしてラップする API やプラグインが様々なジャンルで用意されているため、TS のみでほぼ問題なくアプリを作り上げることができます。

Tauri 自体のツラみ

ここからは話が変わり、TS 書くことに限らない Tauri 自体のちょっとしたツラみを挙げていきます。

環境による差異が発生することがある

Tauri は OS が持つ Web ビューを使用するため、マルチプラットフォームのアプリを作るとき、環境による差異が出ることがあります。挙動が違っていたり、対応していない CSS があったりして、バグやデザイン崩れが起きがちです。

各 OS が持っている Web ビューはとても古いバージョンのブラウザ相当のケースもあるため、普通の Web 系システム開発以上に気を使う必要があります。Baseline が Widely available だから使える、なんてことはないと思いましょう。

ドキュメント不足

全体としてドキュメントの整備がまだ足りていません。

ファイルへアクセスする @tauri-apps/plugin-fs を見ると、「許可を与えるために "permissions" を設定してね」と書いてありますが、どのファイルに記載すればよいのかが書かれていません。実際の対象ファイルは src-tauri/capabilities/default.json で、Capabilities のドキュメントに書かれていますが、このドキュメントにたどり着くのが困難です。

ネット検索して出てくる情報のバージョンが古い

検索して出てくる情報が古い Tauri v1 の話であることがよくあります。Tauri v2 は 2024 年 10 月に正式リリースされており、その前後で使い方などが大きく変わっているため、書かれた時期や内容に注意する必要があります。

特に Google だと公式ドキュメントですら v1 の方が上に表示されるため、注意する必要があります。最近になって「古いバージョンの情報を見ている」という警告が出るようになりましたが、まだ安心できる状況には至っていません。


最後のまとめ

冒頭に述べた通り、Tauri + TypeScript の開発はかなりオススメです。

  • TypeScript だけで充分に実用的なネイティブアプリを作れる。
    • ネイティブアプリとして必要な処理を TypeScript から簡単に呼び出せる。
  • ツラみはあるが、快適さが勝るのでかなりオススメ。
    • Rust を知らなくても良い。
    • Web 系の知識を活かして、その延長でアプリが作れる。
    • 開発ループが速い。

もっと浸透すれば、今後、マルチプラットフォームのネイティブアプリを作るときの第一の選択肢になるのではないか、と思っています。

Discussion