API キーの安全な保管 — OS キーチェーンと Rust コマンド層
Claude API キーを安全に扱うために、Rust 側で keyring クレートを使います。これは macOS Keychain、Windows Credential Manager、Linux Secret Service を共通インターフェースで扱えるラッパーです。
src-tauri/src/secrets.rs を新規作成します。
// src-tauri/src/secrets.rs
// Claude API キーを OS のキーチェーンに保存・取得するモジュール。
// キーをファイルや環境変数で扱わず、必ずこのモジュールを経由させる。
use keyring :: Entry ;
use anyhow :: { Result , Context };
const SERVICE : & str = "net.dolice.claude-desk" ;
const ACCOUNT : & str = "anthropic_api_key" ;
pub fn save_api_key (key : & str ) -> Result <()> {
let entry = Entry :: new ( SERVICE , ACCOUNT )
. context ( "キーチェーンエントリの作成に失敗しました" ) ? ;
entry . set_password (key)
. context ( "キーチェーンへの保存に失敗しました" ) ? ;
Ok (())
}
pub fn load_api_key () -> Result < Option < String >> {
let entry = Entry :: new ( SERVICE , ACCOUNT )
. context ( "キーチェーンエントリの作成に失敗しました" ) ? ;
match entry . get_password () {
Ok (key) => Ok ( Some (key)),
Err ( keyring :: Error :: NoEntry ) => Ok ( None ),
Err (e) => Err ( anyhow :: anyhow! ( "キーチェーンの読み出しに失敗: {}" , e)),
}
}
pub fn delete_api_key () -> Result <()> {
let entry = Entry :: new ( SERVICE , ACCOUNT ) ? ;
match entry . delete_credential () {
Ok (_) => Ok (()),
Err ( keyring :: Error :: NoEntry ) => Ok (()),
Err (e) => Err ( anyhow :: anyhow! ( "キーチェーンの削除に失敗: {}" , e)),
}
}
このモジュールに対する Tauri コマンドを lib.rs(または main.rs)に追加します。
// src-tauri/src/lib.rs(抜粋)
mod secrets ;
#[tauri :: command]
async fn save_api_key (key : String ) -> Result <(), String > {
secrets :: save_api_key ( & key) . map_err ( | e | e . to_string ())
}
#[tauri :: command]
async fn has_api_key () -> Result < bool , String > {
Ok ( secrets :: load_api_key () . map_err ( | e | e . to_string ()) ?. is_some ())
}
#[tauri :: command]
async fn delete_api_key () -> Result <(), String > {
secrets :: delete_api_key () . map_err ( | e | e . to_string ())
}
ここで重要な設計上のポイントは、「API キーを取得するコマンドはフロントエンドに公開しない」 ことです。load_api_key() は Rust 内でのみ使用し、外向きには has_api_key()(存在確認のみ)と保存・削除だけを公開します。こうすることで、たとえ JavaScript 側に脆弱性があっても、API キーそのものがウィンドウのコンテキストに流れてくる経路がなくなります。
フロントエンド側の初期登録 UI は次のようになります。
// src/SettingsView.tsx(抜粋)
import { invoke } from "@tauri-apps/api/core" ;
import { useState } from "react" ;
export function SettingsView () {
const [ apiKey , setApiKey ] = useState ( "" );
const [ saving , setSaving ] = useState ( false );
const [ error , setError ] = useState < string | null >( null );
const handleSave = async () => {
setSaving ( true );
setError ( null );
try {
// Rust 側のコマンドを呼ぶ。キーはここで一度だけ受け渡され、保存後は破棄。
await invoke ( "save_api_key" , { key: apiKey });
setApiKey ( "" ); // メモリから即座に消す
alert ( "API キーを安全に保存しました" );
} catch (e) {
setError ( String (e));
} finally {
setSaving ( false );
}
};
return (
< div >
< input
type = "password"
value = { apiKey }
onChange = { ( e ) => setApiKey (e.target.value) }
placeholder = "sk-ant-..."
autoComplete = "off"
/>
< button onClick = { handleSave } disabled = { saving || ! apiKey } >
保存
</ button >
{ error && < div role = "alert" > { error } </ div > }
</ div >
);
}
Rust から Claude API を呼ぶ — 一括応答とエラーハンドリング
ストリーミングに進む前に、まずシンプルな一括応答で API 呼び出しの基盤を整えます。src-tauri/src/anthropic.rs を作成します。
// src-tauri/src/anthropic.rs
// Anthropic API クライアント(最小実装)。
// fetch 部分を一箇所にまとめておくと、ヘッダ追加やリトライ実装が局所化できる。
use serde :: { Deserialize , Serialize };
use reqwest :: Client ;
use anyhow :: { Result , Context , bail};
const API_BASE : & str = "https://api.anthropic.com/v1" ;
const API_VERSION : & str = "2023-06-01" ;
#[derive( Serialize , Clone )]
pub struct Message {
pub role : String ,
pub content : String ,
}
#[derive( Serialize )]
struct MessagesRequest <' a > {
model : & ' a str ,
max_tokens : u32 ,
messages : & ' a [ Message ],
#[serde(skip_serializing_if = "Option::is_none" )]
system : Option < & ' a str >,
#[serde(skip_serializing_if = "Option::is_none" )]
stream : Option < bool >,
}
#[derive( Deserialize )]
pub struct MessagesResponse {
pub content : Vec < ContentBlock >,
pub stop_reason : Option < String >,
pub usage : Usage ,
}
#[derive( Deserialize )]
pub struct ContentBlock {
#[serde(rename = "type" )]
pub kind : String ,
pub text : Option < String >,
}
#[derive( Deserialize )]
pub struct Usage {
pub input_tokens : u32 ,
pub output_tokens : u32 ,
}
pub async fn send_message (
api_key : & str ,
model : & str ,
system : Option < & str >,
messages : & [ Message ],
max_tokens : u32 ,
) -> Result < MessagesResponse > {
let client = Client :: new ();
let body = MessagesRequest {
model,
max_tokens,
messages,
system,
stream : None ,
};
let res = client
. post ( format! ( "{}/messages" , API_BASE ))
. header ( "x-api-key" , api_key)
. header ( "anthropic-version" , API_VERSION )
. header ( "content-type" , "application/json" )
. json ( & body)
. send ()
.await
. context ( "Anthropic API への接続に失敗しました" ) ? ;
let status = res . status ();
if ! status . is_success () {
let text = res . text () .await. unwrap_or_default ();
bail! ( "Anthropic API エラー {}: {}" , status, text);
}
res . json :: < MessagesResponse >()
.await
. context ( "Anthropic API レスポンスのパースに失敗しました" )
}
「なぜ自前の薄いラッパーにしたか」と問われると、Rust 側の Anthropic 公式 SDK は急速に成熟していますが、Tauri アプリのように依存を最小化したい用途では、reqwest 直叩きの方が体重が軽く、デバッグもしやすいというのが私の感覚です。プロジェクトが大きくなったら公式 SDK へ載せ替える、という二段構えで十分だと思います。
SSE ストリーミング — Rust から JS にトークン単位でイベントを送る
デスクトップ AI アプリでユーザー体験を決定的に変えるのがストリーミング表示です。Tauri ではフロントエンドへ任意のイベントを送れる app.emit() があるので、Rust で SSE をパースしながらトークンを 1 つずつ送ればよい設計になります。
// src-tauri/src/anthropic.rs に追加
use futures_util :: StreamExt ;
use tauri :: { AppHandle , Emitter };
#[derive( Serialize , Clone )]
pub struct StreamChunk {
pub session_id : String ,
pub delta : String ,
}
#[derive( Serialize , Clone )]
pub struct StreamDone {
pub session_id : String ,
pub stop_reason : Option < String >,
pub input_tokens : u32 ,
pub output_tokens : u32 ,
}
pub async fn stream_message (
app : AppHandle ,
api_key : & str ,
session_id : String ,
model : & str ,
system : Option < & str >,
messages : & [ Message ],
max_tokens : u32 ,
) -> Result <()> {
let client = Client :: new ();
let body = MessagesRequest {
model,
max_tokens,
messages,
system,
stream : Some ( true ),
};
let res = client
. post ( format! ( "{}/messages" , API_BASE ))
. header ( "x-api-key" , api_key)
. header ( "anthropic-version" , API_VERSION )
. header ( "content-type" , "application/json" )
. json ( & body)
. send ()
.await? ;
if ! res . status () . is_success () {
bail! ( "Anthropic API エラー {}: {}" , res . status (), res . text () .await. unwrap_or_default ());
}
let mut stream = res . bytes_stream ();
let mut buf = String :: new ();
let mut input_tokens = 0 u32 ;
let mut output_tokens = 0 u32 ;
let mut stop_reason : Option < String > = None ;
while let Some (chunk) = stream . next () .await {
let bytes = chunk . context ( "ストリームの読み取りに失敗しました" ) ? ;
buf . push_str ( & String :: from_utf8_lossy ( & bytes));
// SSE は "\n\n" 区切りでイベントが完結する
while let Some (idx) = buf . find ( " \n\n " ) {
let event = buf[ .. idx] . to_string ();
buf . drain ( .. idx + 2 );
for line in event . lines () {
if let Some (data) = line . strip_prefix ( "data: " ) {
let v : serde_json :: Value = match serde_json :: from_str (data) {
Ok (v) => v,
Err (_) => continue ,
};
match v[ "type" ] . as_str () {
Some ( "content_block_delta" ) => {
if let Some (text) = v[ "delta" ][ "text" ] . as_str () {
app . emit (
"claude://stream" ,
StreamChunk {
session_id : session_id . clone (),
delta : text . to_string (),
},
) ? ;
}
}
Some ( "message_start" ) => {
input_tokens = v[ "message" ][ "usage" ][ "input_tokens" ]
. as_u64 ()
. unwrap_or ( 0 ) as u32 ;
}
Some ( "message_delta" ) => {
if let Some (out) = v[ "usage" ][ "output_tokens" ] . as_u64 () {
output_tokens = out as u32 ;
}
if let Some (reason) = v[ "delta" ][ "stop_reason" ] . as_str () {
stop_reason = Some (reason . to_string ());
}
}
_ => {}
}
}
}
}
}
app . emit (
"claude://done" ,
StreamDone {
session_id,
stop_reason,
input_tokens,
output_tokens,
},
) ? ;
Ok (())
}
Tauri コマンドからこの関数を呼び出す箇所では、AppHandle を引数に取れる点を活用します。
// src-tauri/src/lib.rs(抜粋)
#[tauri :: command]
async fn chat_stream (
app : tauri :: AppHandle ,
session_id : String ,
messages : Vec <anthropic :: Message >,
system : Option < String >,
) -> Result <(), String > {
let api_key = secrets :: load_api_key ()
. map_err ( | e | e . to_string ()) ?
. ok_or ( "API キーが設定されていません" . to_string ()) ? ;
anthropic :: stream_message (
app,
& api_key,
session_id,
"claude-sonnet-4-6" ,
system . as_deref (),
& messages,
4096 ,
)
.await
. map_err ( | e | e . to_string ())
}
フロントエンド側はイベントリスナーを購読するだけです。
// src/ChatView.tsx(抜粋)
import { invoke } from "@tauri-apps/api/core" ;
import { listen, UnlistenFn } from "@tauri-apps/api/event" ;
import { useEffect, useRef, useState } from "react" ;
type Msg = { role : "user" | "assistant" ; content : string };
export function ChatView () {
const [ messages , setMessages ] = useState < Msg []>([]);
const [ input , setInput ] = useState ( "" );
const [ streaming , setStreaming ] = useState ( false );
const sessionIdRef = useRef ( "" );
useEffect (() => {
let unlistenChunk : UnlistenFn | null = null ;
let unlistenDone : UnlistenFn | null = null ;
( async () => {
unlistenChunk = await listen <{ session_id : string ; delta : string }>(
"claude://stream" ,
( event ) => {
if (event.payload.session_id !== sessionIdRef.current) return ;
setMessages (( prev ) => {
const next = [ ... prev];
const last = next[next. length - 1 ];
if (last?.role === "assistant" ) {
next[next. length - 1 ] = {
... last,
content: last.content + event.payload.delta,
};
}
return next;
});
}
);
unlistenDone = await listen ( "claude://done" , () => setStreaming ( false ));
})();
return () => {
unlistenChunk ?.();
unlistenDone ?.();
};
}, []);
const send = async () => {
if ( ! input. trim () || streaming) return ;
const sid = crypto. randomUUID ();
sessionIdRef.current = sid;
const next : Msg [] = [
... messages,
{ role: "user" , content: input },
{ role: "assistant" , content: "" },
];
setMessages (next);
setInput ( "" );
setStreaming ( true );
try {
await invoke ( "chat_stream" , {
sessionId: sid,
messages: next. slice ( 0 , - 1 ),
system: "あなたはユーザーの開発作業を丁寧に支援するアシスタントです。" ,
});
} catch (e) {
setStreaming ( false );
alert ( `送信に失敗しました: ${ e }` );
}
};
// …UI は省略…
}
設計上のコツとして、session_id を必ず付ける ことを強くお勧めします。ユーザーが立て続けに送信した場合に、前のセッションのデルタが新しい吹き出しに混入する事故を防げます。また、ユーザーがウィンドウを閉じた瞬間に現在のセッション ID を破棄する処理を入れておけば、応答途中のキャンセルも自然に書けます。
Tool Use を Rust 側で扱う — 「ローカルファイル要約」を実装する
デスクトップアプリで Claude を使う最大の利点は、ローカルファイルへのアクセスを Tool Use として安全に提供できる 点です。ブラウザでは原理的に難しい体験を、Tauri ならファイルダイアログ経由でユーザーが許可した範囲だけに制限して実装できます。
ここでは「ユーザーが選んだファイルを読み込んで要約してもらう」最小例を示します。
// src-tauri/src/tools.rs
use serde_json :: {json, Value };
use std :: fs;
use std :: path :: Path ;
pub fn tool_definitions () -> Value {
json! ([
{
"name" : "read_local_file" ,
"description" : "ユーザーが事前に許可したパスのテキストファイルを読み出す。バイナリファイルは扱わない。" ,
"input_schema" : {
"type" : "object" ,
"properties" : {
"path" : { "type" : "string" , "description" : "読み出す絶対パス" }
},
"required" : [ "path" ]
}
}
])
}
pub fn execute_tool (name : & str , input : & Value , allowed_dirs : & [ String ]) -> Result < String , String > {
match name {
"read_local_file" => {
let path = input[ "path" ] . as_str () . ok_or ( "path が必要です" ) ? ;
let abs = Path :: new (path);
// パスが許可ディレクトリ配下であることを確認(pathトラバーサル対策)
let allowed = allowed_dirs . iter () . any ( | d | abs . starts_with ( Path :: new (d)));
if ! allowed {
return Err ( format! ( "許可されていないパスです: {}" , path));
}
let content = fs :: read_to_string (abs)
. map_err ( | e | format! ( "読み込み失敗: {}" , e)) ? ;
// 大きすぎる場合は先頭だけ返す
let truncated = if content . len () > 200_000 {
format! ( "{} \n\n […本文が長いため先頭 200KB のみを返しました…]" , & content[ .. 200_000 ])
} else {
content
};
Ok (truncated)
}
_ => Err ( format! ( "未知のツール: {}" , name)),
}
}
Tool Use のループは、Anthropic API のレスポンスに stop_reason: "tool_use" が含まれている場合に、ツール実行結果を tool_result として再度送り返す形になります。詳細な実装パターンは Claude API の Tool Use を本番で安定させる設計パターン を併読いただくと理解が深まると思います。
ここで本当に強調したいのは、「allowed_dirs に存在しないパスは絶対に読まない」というガードを Rust 側で持つこと です。フロントエンド経由で来る path を信用してはいけません。Path Traversal は依然として現役の脆弱性で、Claude が善意で生成したパスでも、誤ってシステムファイルを読みに行くリスクは現実にあります。
設定ファイル・履歴・テーマ — Tauri の Store プラグインを使う
会話履歴やユーザー設定の保存には、tauri-plugin-store を使うのが最も素直です。SQLite を立てるほどでもない規模で、JSON 永続化で十分なケースが大半です。
npm install @tauri-apps/plugin-store
cargo add tauri-plugin-store --manifest-path src-tauri/Cargo.toml
Rust 側の登録と、フロントエンドからの利用例をまとめて示します。
// src-tauri/src/lib.rs(抜粋)
pub fn run () {
tauri :: Builder :: default ()
. plugin ( tauri_plugin_store :: Builder :: default () . build ())
. plugin ( tauri_plugin_shell :: init ())
. plugin ( tauri_plugin_updater :: Builder :: new () . build ())
. invoke_handler ( tauri :: generate_handler! [
save_api_key,
has_api_key,
delete_api_key,
chat_stream,
])
. run ( tauri :: generate_context! ())
. expect ( "error while running tauri application" );
}
// src/store.ts
import { Store } from "@tauri-apps/plugin-store" ;
const store = await Store. load ( "history.json" );
export async function saveConversation ( id : string , messages : unknown ) {
await store. set (id, { messages, updated_at: Date. now () });
await store. save ();
}
export async function loadConversations () {
const entries = await store. entries ();
return entries. sort (( a : any , b : any ) => b[ 1 ].updated_at - a[ 1 ].updated_at);
}
ファイルは OS のアプリデータディレクトリ配下(macOS なら ~/Library/Application Support/{identifier}/)に保存されるので、ユーザーがアプリを再インストールしても消えません。逆に、テスト中に「履歴をリセットしたい」場合はこのディレクトリを手動で消す必要があるのを覚えておくと便利です。
よくある間違い・落とし穴
ここまで実装するなかで、私自身が一度ハマったか、相談を受けた頻度が高いポイントをまとめます。同じ場所で時間を溶かさないようにしてください。
1. レンダラー側で fetch('https://api.anthropic.com/...') を直接呼ぶ
これは Tauri に限らず Electron 時代から続く誤りですが、Tauri では特に「やるとビルドが通ってしまうのでバレにくい」という危険があります。CORS の壁を回避するために tauri.conf.json の app.security.csp を緩めると、API キーがウィンドウのコンテキストに必ず登場する設計になります。API 呼び出しは必ず Rust コマンドにラップする こと。これが Tauri を選ぶ最大の理由のひとつです。
2. tauri.conf.json のバンドル識別子をデフォルトのまま放置
com.tauri.dev のような識別子のまま macOS の署名・公証に出すと、Apple Developer ID と紐づかずに落ちます。net.dolice.claude-desk のように 自分のドメインを逆順にした識別子 へ最初に書き換えておくと、後から困ることがありません。Mac App Store 配布を視野に入れるなら、この識別子は後から変更すると Keychain エントリも作り直しになるため、最初の判断が重要です。
3. ストリーミングで app.emit を 1 トークンごとに発行して UI が固まる
Rust 側は超高速にイベントを送れますが、JavaScript 側で setMessages を毎回呼ぶと React の再レンダリングが過剰に走ります。私の経験では、16ms(1 フレーム)に 1 回だけ flush するバッファ を Rust 側か JS 側で挟むと体感が劇的に良くなります。最初は素直に書いて遅さを実感してから、必要に応じて入れるくらいで十分です。
4. macOS の Notarization で WebView2 関連の警告を「無視」してしまう
macOS は Notarization 結果に細かなログを返します。「WebView 関連のシンボルが Apple のリストに含まれない」といった警告は、ほとんどが無害ですが、本番リリース直前に必ず xcrun notarytool log で内容を確認する習慣をつけてください。リリース後に「特定のユーザーだけ起動できない」という現象を引き起こすのは、たいていここを読み飛ばした警告に伏線があります。
5. Windows の WebView2 が古い環境で起動しない
Windows 10 の初期 WebView2 ランタイムが入っていない PC では、Tauri アプリは無音で落ちます。インストーラーに WebView2 の自動インストールを組み込めるオプションが Tauri にあるので、Windows をターゲットに含めるなら必ず有効化してください。tauri.conf.json の bundle.windows.webviewInstallMode を embedBootstrapper に設定するのがおすすめです。
ビルドと配布 — macOS 署名・Notarization・自動アップデート
開発の最後に立ちはだかるのが「どうやってユーザーに届けるか」です。Tauri 2 はこのあたりがかなり洗練されており、.dmg(macOS)・.msi(Windows)・.AppImage(Linux)を 1 コマンドで生成できます。
# 環境変数で署名・Notarization 設定を渡す
export APPLE_SIGNING_IDENTITY = "Developer ID Application: Your Name (TEAMID)"
export APPLE_ID = "you@example.com"
export APPLE_PASSWORD = "app-specific-password"
export APPLE_TEAM_ID = "TEAMID"
# プロダクションビルド
npm run tauri build
# 出力例(macOS)
# src-tauri/target/release/bundle/dmg/claude-desk_0.1.0_aarch64.dmg
# src-tauri/target/release/bundle/macos/claude-desk.app
自動アップデートは tauri-plugin-updater を使い、GitHub Releases を配信元にするのが個人開発では最も簡単です。tauri.conf.json の plugins.updater.endpoints に https://github.com/{owner}/{repo}/releases/latest/download/latest.json を指定し、リリース時に latest.json(バージョン・URL・署名)を生成してアップロードする CI を組めば終わりです。
GitHub Actions であれば、tauri-action がこのフロー全体(ビルド・署名・リリース・latest.json 生成)を 1 ステップでやってくれるので、私はこれを推しています。手書きでフローを組むより、結果的にメンテ工数が小さくなります。
Rust ブリッジのテスト — UI を立てずに高速フィードバックを得る
Tauri プロジェクトで生産性を高く保つコツは、Rust 側を普通のクレートとして単体テストできる構造に保つ ことだと考えています。私が混入させるバグの大半は SSE パーサ・Tool Use ガード・キーチェーンラッパーのいずれかに住んでおり、どれも UI を起動する必要がありません。
// src-tauri/src/anthropic.rs(末尾に追加)
#[cfg(test)]
mod tests {
use super ::* ;
#[test]
fn parses_content_block_delta_lines () {
let line = r#"data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"hello"}}"# ;
let raw = line . strip_prefix ( "data: " ) . unwrap ();
let v : serde_json :: Value = serde_json :: from_str (raw) . unwrap ();
assert_eq! (v[ "type" ], "content_block_delta" );
assert_eq! (v[ "delta" ][ "text" ], "hello" );
}
#[test]
fn ignores_unknown_event_types () {
let v : serde_json :: Value =
serde_json :: from_str ( r#"{"type":"ping"}"# ) . unwrap ();
// ストリーミングループはこれらをスキップして panic しないこと。
// 将来の読者向けにこの契約をテストとして明文化しておく。
assert_eq! (v[ "type" ], "ping" );
}
}
実行は cargo test --manifest-path src-tauri/Cargo.toml です。テストは「少なく・意味のあるもの」だけに保ちます。私が過去にリリースしてしまった回帰の多くは、結局のところパーサの分岐でカバーし忘れていたケースで、この程度のテストでも 1〜2 リリースで元が取れます。
もうひとつ取り入れたい習慣として、キーチェーンを mock-keyring フィーチャでモックする ことを挙げておきます。keyring クレートにはまさにこの用途のフィーチャがあるので、CI 環境に実 OS のキーチェーンを必須にせずに済みます。
Tauri 2 が数字で効く場面
「移行コストに見合うか」を判断する材料として、私が手がけたいくつかのアプリで観測した数字をざっくり共有します。
「Claude API 1 系統 + 履歴永続化 + 設定画面」程度の小さなチャットアプリで、私の計測ではおおむね次のレンジに収まりました。
macOS arm64 のバンドルサイズ: Tauri 2 で約 4.2 MB、Electron 35 で約 96 MB(同等機能)
M2 MacBook Air でのコールドスタート: Tauri が約 480 ms、Electron が約 1.6 s
起動 1 分後のアイドル時メモリ常駐量: Tauri が約 70 MB、Electron が約 230 MB
「送信」クリックから最初のトークンまで: ネットワーク待ちが支配的で、実用上の差はなし
つまり、ストリーミングの体感は両者でほぼ同じで、ユーザーが本当に感じるのは 「最初の文字を打ち込む前の体験」全体 です。多くの人はその瞬間にアプリを使い続けるかどうかを決めるので、サイズと起動の差は数字以上に効きます。
もちろんコスト側も無視できません。Rust の学習コスト、Tauri エコシステムの若さ、Node 専用ライブラリしかない領域での移植の重さ、これらは現実の課題です。Claude API を 1 つ叩いて、テキストを高速にレンダリングする — そういうコアの単純な個人開発 AI アプリでは、私はもう Tauri を最初の選択肢にしています。
tauri dev の体験を磨く小ネタ
「知ってさえいれば一瞬で楽になる」設定をいくつか挙げておきます。
tauri.conf.json の build.beforeDevCommand と build.beforeBuildCommand を設定すると、npm run tauri dev 時にフロントエンドの dev サーバが自動起動します。当たり前のようですが、長年ターミナルを 2 つ開いていたチームを何度も見ました
JavaScript 側で独自にドラッグ&ドロップを実装するなら app.windows[0].fileDropEnabled = false を入れること。さもないと Tauri 側が先にイベントを掴んでしまい、JS のハンドラが発火しません
npm run tauri dev を RUST_LOG=info 付きで起動すると、Rust 側の panic と warn がターミナルに出るようになります。これがないとエラーが「無音で消える」状態になり、原因追跡に余計な時間がかかります
ここに 30 分投資しておくと、後の数日分の時間が浮きます。「動いてしまう」ので見落としがちな領域です。
比較 — Tauri 2 を「選ばない方がいい」ケース
ひとつのツールを延々と褒めるだけでは公平でないので、Tauri 2 を選ばない方がいいケースも正直に挙げておきます。
Node 専用エコシステムに重心がある 場合 — Monaco を深く組み込み独自言語サービスを乗せる、Electron 用のプラグインマーケットがある、File System Access API のような Chromium 限定 API に依存する — このような状況なら、引き続き Electron が現実解です。Rust への橋渡しコストが、バンドルサイズの節約を上回ります。
UI ファーストの試作を 1 週間以内に検証したい 場合は、Wails(Go + WebView)や、PWA を electron-builder でラップする選択肢の方がスピードに乗れます。Tauri の Rust は親しみやすい方ですが、「親しみやすい Rust」もやはり Rust です。
Mac App Store 配布 を中心に据える場合、Tauri の署名フローは動きはするものの、サンドボックスまわりの entitlements.plist で多少の試行錯誤が必要です。Electron の方が公開事例が多くコピーしやすいのは事実です。entitlements の迷宮はそれだけで 1 本記事になるので、別途まとめる予定です。
それ以外、App Store の外で配布する小規模・個人開発の Claude API アプリ では、Tauri 2 を私は標準にしています。軽くて速い、そして Rust 層という「秘密情報を厳密に扱える場所」が手に入るのは、純 JavaScript ランタイムでは得られない価値です。
判断材料として有効な読み合わせは、本記事と Claude API × Electron デスクトップAIアプリ本番実装完全ガイド を並べて、特にキーチェーン節とストリーミング節を見比べることです。Electron 側の Context Isolation まわりの複雑さこそが、Tauri では Rust コマンド境界に置き換わる部分です。
全体を振り返って — 個人開発でデスクトップ AI を出すなら、Tauri 2 は最有力
ここまで読んでいただき、ありがとうございます。Tauri 2 を選ぶ最大の価値は、「個人開発者でも、Apple や Microsoft のストアに頼らずに、ネイティブに近い体験のデスクトップ AI アプリを単独でリリースできる」 という現実が手の届く範囲に来たことだと、私は感じています。Rust というハードルはありますが、Anthropic API クライアント部分に絞ればコードは想像よりずっと短くまとまります。
次の一歩として、まずは本記事のコードを写経しながら 「自分の OS のキーチェーンに API キーを保存し、Rust 経由で SSE ストリーミングが流れる」 ところまでを 1 日で完走してみてください。ここまで動けば、その後の機能追加(履歴管理・Tool Use・配布)はすべて応用です。デスクトップ AI アプリの設計に踏み込みたい方は、Claude API × Electron デスクトップAIアプリ本番実装完全ガイド と読み比べると、技術選定の勘所がさらに明確になるはずです。