← ブログに戻る

なぜElectronアプリをTauri/Rustで書き直したのか

engineering tauri rust

Electronを捨てた理由

GitSquid v1はElectronで構築されていました。動作していました。ユーザーはリポジトリの閲覧、変更のステージング、ブランチの管理、リモートの操作、統合ターミナルの使用――これらすべてを1つのデスクトップアプリから行えました。内部では、Git操作にsimple-git、ターミナルにnode-ptyxterm.js、UIに通常のReactスタックを使用していました。

しかし、アプリが成熟するにつれて、Electronのトレードオフは無視しづらくなりました。機能的な製品を出荷していましたが、ランタイム自体が私たちの足を引っ張っていました。そこで、バックエンド全体をTauri 2.xを使ってRustで書き直しました――Reactフロントエンドの95%はそのまま維持しながら。

これは、なぜそうしたのか、どうやったのか、そしてその過程で何が壊れたかの記録です。

Electronの問題点

Electronは実績のあるフレームワークです。VS Code、Slack、Discord――主要な製品がその上で動いています。しかし、Gitクライアントにとって、そのオーバーヘッドはタスクに対して不釣り合いです。

  • アプリサイズ:ElectronはChromiumブラウザ全体とNode.jsランタイムをバンドルします。パッケージ化されたアプリは150 MB以上でした。Gitクライアントとしては正当化が難しいサイズです。
  • メモリ使用量:Electronの各ウィンドウは複数のChromiumプロセスを生成します。アイドル時のメモリ消費は、同じ作業に対してネイティブアプリが必要とする量をはるかに上回っていました。
  • 起動時間:Chromiumの起動は即座ではありません。特に古いハードウェアでは、アプリがインタラクティブになるまでに知覚できる遅延がありました。
  • ネイティブな感触:ネイティブタイトルバーやメニューに関するElectronの努力にもかかわらず、アプリはOSに馴染んでいる感覚を持てませんでした。ウィンドウ管理、フォーカスの動作、キーボードショートカットといった細かな部分に、常にWebアプリの匂いが残っていました。

これらのどれも、単独では致命的ではありません。しかし組み合わさると、開発者ツールに求められる体験――高速、軽量、透明――とは合致しない摩擦を生み出していました。

なぜTauri 2.xか

Tauriに決める前に、いくつかの代替案を評価しました。決定要因は明確でした:

  • バンドルされたChromiumではなくシステムWebView。TauriはOSが提供するWebView(macOSではWebKit、WindowsではWebView2、LinuxではWebKitGTK)を使用します。これだけでElectronのバイナリサイズの大部分を削減できます。
  • Rustバックエンド。バックエンドはコンパイル済みのRustバイナリとして動作します。メモリ安全性、ガベージコレクターなし、予測可能なパフォーマンス。インタラクションごとに数十のGit操作を実行するツールにとって、これは重要です。
  • 劇的に小さなバイナリ。Tauriアプリは10 MB未満で出荷できます。150 MB以上と比較してください。
  • ネイティブ統合を備えたクロスプラットフォーム。Tauri 2.xはネイティブメニュー、システムトレイ、通知、ファイルダイアログのための堅実なAPIを提供します。アプリがOSに馴染んでいる感覚が得られます。
  • 活発なエコシステムと良好な開発者体験。Tauriのプラグインシステム、IPCモデル、ドキュメントは2.xリリースで大きく成熟しました。本番利用の準備が整っていました。

移行戦略

フルリライトはリスクがあります。関心の分離を明確にすることで、そのリスクを軽減しました。

フロントエンドを維持し、バックエンドを書き直す

ReactフロントエンドはすでにIPC境界を中心に構造化されていました。コンポーネントはElectronメインプロセスと通信する関数を呼び出していたため、それらの呼び出しをTauriのinvoke APIにリダイレクトするだけで済みました。Reactコードの約95%が変更なしで移行できました。

バックエンドは別の話でした。すべてのNode.jsサービス――Git操作、ファイル監視、ターミナル管理、認証、設定――をRustで再実装する必要がありました。合計で約7,200行のRustを書き、120以上のIPCコマンドを移行しました。

libgit2ではなくGit CLI

よくある質問:なぜgit2-rs(libgit2のRustバインディング)を使わないのか?

Git CLIを直接呼び出すためにstd::process::Commandを選びました。その理由:

  • libgit2はすべてのGit機能をサポートしているわけではありません。インタラクティブrebase、一部のmerge戦略、特定の設定オプションなどは、欠落しているか動作が異なります。
  • ユーザーはすでにGitをインストールしています。ローカルのGitバイナリを呼び出すことで、コマンドラインとの動作の一致を保証します。ターミナルで動けば、GitSquidでも動きます。
  • デバッグがシンプルになります。実行するコマンドはユーザーが入力するのと同じです。何か問題が起きた時、エラーメッセージは馴染みのあるものです。

Node.jsエコシステムの置き換え

各Node.js依存関係をRustの同等物で置き換えました:

  • simple-gitstd::process::Command経由の直接Git CLI呼び出しに
  • node-ptyはターミナルエミュレーション用のportable-pty
  • トークンの保存はOSキーチェーン(macOS Keychain、Windows Credential Manager、Linux Secret Service)を使用するkeyring crateに
  • ファイル監視はnotify crateに
  • HTTPリクエストはreqwest

シームレスな設定移行

同じ設定ディレクトリ構造を維持しました。v1からv2にアップグレードするユーザーは、設定、保存されたリポジトリ、プリファレンスを失いません。RustバックエンドはNode.jsバックエンドが書き込んだのと同じ設定ファイルを読み取ります。

直面した課題

移行は順風満帆ではありませんでした。いくつかの問題が相当なデバッグ時間を要しました。

Gitログパースにおけるヌルバイト

コミットデータのパースにgit log --formatでカスタムフォーマット文字列を使用しています。Electron版では、フィールド間に特定のセパレータ文字を使っていました。Rustへの移植時、最初はヌルバイト(\x00)をフィールドセパレータとして使おうとしました――コミットメッセージに出現することがないため、安全な選択に思えました。

間違いでした。Rustの文字列はヌルバイトを問題なく扱えますが、スタックのいくつかの層がそれをCスタイルの文字列終端子として扱います。ログ出力は最初のヌルバイトで無言のうちに切り捨てられていました。Gitの出力に自然に出現することのない複数文字のデリミタに切り替えました。

キーチェーンのトークン保存

keyring crateはOSキーチェーンに認証情報を保存します。これは正しいアプローチです。しかし、同様のヌルバイト問題に遭遇しました:保存前にトークンデータをヌルバイトセパレータで結合していたのです。取得時、一部のOSキーチェーン実装が最初のヌルバイトで値を切り捨て、不完全な認証情報を返していました。解決策は同じでした――可視的で明確なセパレータを使用することです。

BitBucket認証の複雑さ

GitHubとGitLabは比較的わかりやすいトークンベースの認証フローを持っています。BitBucketはより複雑です。操作やアカウントの種類によって、ユーザー名、メールアドレス、アプリパスワード、またはOAuthトークンが必要になる場合があり――どれをどこで使うかのルールは必ずしも明確ではありません。clone、fetch、push、API呼び出し全体でBitBucket認証を確実に動作させるには、数回のイテレーションが必要でした。

行単位のステージング

GitSquidはdiffからhunk全体だけでなく、個別の行のステージングをサポートしています。Electron版では、パッチ生成はJavaScriptで行われていました。Tauri版では、このロジックをRustバックエンドに移しました。任意の行選択から有効なGitパッチフォーマットを生成し、コンテキスト行のエッジケースを処理し、git applyが結果を受け入れることを確認する――これはリライトの中で最も複雑な部分の1つでした。Rust実装は最終的に、元のJavaScript版よりも堅牢になりました。

フランス語のアポストロフィがビルドを壊す

これは記憶に残る問題でした。ユーザー向けの文字列の一部に、ストレートなASCIIアポストロフィではなく、フランス語のタイポグラフィックアポストロフィ(カールした種類、U+2019)が含まれていました。これが特定のCI設定でビルドプロセス中にエンコーディング問題を引き起こしました。問題が特定されれば修正は些細なものでしたが、エラーメッセージは実際の問題のそばすら指していませんでした。

結果

移行を完了し、完全な機能パリティを達成した後の結果です:

  • アプリサイズ:Electronビルドと比較して大幅に削減。Tauriバンドルは、以前出荷していた150 MB以上のほんの一部です。
  • メモリ使用量:ベースラインが低く、負荷時もより予測可能。バックグラウンドでRAMを消費するChromiumプロセスツリーはありません。
  • 起動時間:明らかに高速。アプリはほぼ即座にインタラクティブになります。
  • 機能パリティ:100%。v1のすべての機能がv2に存在します。統合ターミナル、行単位ステージング、マルチリモートサポート、ブランチ管理――すべてです。
  • フロントエンドの再利用:Reactコードベースの95%が移行でき、クリーンなIPC境界を維持するというアーキテクチャ上の決定が正しかったことを実証しました。

やる価値はあったか

はい。

リライトには相当な労力がかかりました。約7,200行のRust、120以上のIPCコマンドの再実装、そしてRustとNode.jsがエッジケースを異なる方法で処理することで初めて表面化したいくつかの非自明なバグ。週末プロジェクトではありませんでした。

しかし、結果は根本的に優れたアプリケーションです。GitSquid v2はより速く起動し、メモリ消費が少なく、より小さなバイナリとして出荷され、すべてのプラットフォームでよりネイティブに感じられます。Rustバックエンドは、Node.jsの同等物よりも予測可能で理解しやすいです。そして同じReactフロントエンドを維持したため、ユーザーはすでに慣れ親しんだ同じインターフェースを目にします。

Electronアプリを保守していてランタイムのオーバーヘッドが気になるなら、Tauriは真剣に検討すべき選択肢です。エコシステムは本番利用に十分成熟しており、移行パス――特にすでにクリーンなフロントエンド/バックエンドの分離がある場合――はフルリライトが示唆するほど困難ではありません。

重要な洞察:IPC境界をAPIコントラクトとして扱うことです。フロントエンドがinvoke('get_commits', { path, branch })の呼び出し方だけを知っていて、レスポンスがNode.jsから来るかRustから来るかを気にしないなら、バックエンドの入れ替えは空想ではなく現実的なプロジェクトになります。