RustバックエンドでAPIキーを安全に管理する
Tauriのセキュリティモデルの核心は、フロントエンドJavaScriptがAPIキーに触れない設計 です。APIキーはRustバックエンドだけが知り、フロントエンドはinvoke()でRustの関数を呼ぶだけです。
src-tauri/src/main.rsを以下のように実装します:
// src-tauri/src/main.rs
#\ ! [ cfg_attr ( not (debug_assertions), windows_subsystem = "windows" )]
use reqwest :: Client ;
use serde :: { Deserialize , Serialize };
use std :: sync :: OnceLock ;
// アプリ全体でHTTPクライアントを再利用(接続プールの活用)
static HTTP_CLIENT : OnceLock < Client > = OnceLock :: new ();
fn get_client () -> & ' static Client {
HTTP_CLIENT . get_or_init ( || {
Client :: builder ()
. timeout ( std :: time :: Duration :: from_secs ( 120 ))
. build ()
. expect ( "HTTPクライアントの初期化に失敗しました" )
})
}
fn get_api_key () -> Result < String , String > {
std :: env :: var ( "GEMINI_API_KEY" )
. map_err ( | _ | {
"GEMINI_API_KEY が設定されていません。\
.env ファイルを src-tauri/ に配置してください。" . to_string ()
})
}
fn main () {
// 開発ビルド時のみ .env を読み込む
// 本番バイナリには .env ファイルは含まれない
#[cfg(debug_assertions)]
dotenvy :: dotenv () . ok ();
tauri :: Builder :: default ()
. plugin ( tauri_plugin_shell :: init ())
. plugin ( tauri_plugin_notification :: init ())
. plugin ( tauri_plugin_clipboard_manager :: init ())
. invoke_handler ( tauri :: generate_handler\ ! [
generate_text,
generate_text_stream,
])
. run ( tauri :: generate_context\ ! ())
. expect ( "Tauriアプリの起動に失敗しました" );
}
本番向けのAPIキー管理には2つのアプローチがあります。①ユーザーが自分のAPIキーを設定する (自分用ツールや技術者向けアプリに適している)か、②自前のプロキシサーバーを使う (一般配布向け。Cloudflare WorkersなどでAPIキーを管理し、アプリからはそこにリクエストする)かです。後者の場合はRustコードのurl変数を自前のエンドポイントに変更するだけです。
テキスト生成コマンドの実装
#[tauri::command]アトリビュートを付けた非同期関数がフロントエンドから呼び出せる関数になります。エラーはRustのResult<T, String>で返し、フロントエンドではinvoke()が返すPromiseでキャッチします。
#[derive( Serialize , Deserialize )]
struct GeminiRequest {
contents : Vec < GeminiContent >,
}
#[derive( Serialize , Deserialize )]
struct GeminiContent {
parts : Vec < GeminiPart >,
}
#[derive( Serialize , Deserialize )]
struct GeminiPart {
text : String ,
}
#[tauri :: command]
async fn generate_text (prompt : String ) -> Result < String , String > {
let api_key = get_api_key () ? ;
let client = get_client ();
let url = format\ ! (
"https://generativelanguage.googleapis.com/v1beta/\
models/gemini-2.5-pro-latest:generateContent?key={}" ,
api_key
);
let request_body = GeminiRequest {
contents : vec\ ! [ GeminiContent {
parts : vec\ ! [ GeminiPart { text : prompt }],
}],
};
let response = client
. post ( & url)
. json ( & request_body)
. send ()
.await
. map_err ( | e | format\ ! ( "ネットワークエラー: {}" , e)) ? ;
if \ ! response . status () . is_success () {
let status = response . status ();
let body = response . text () .await. unwrap_or_default ();
return Err (format\ ! ( "Gemini APIエラー (HTTP {}): {}" , status, body));
}
let json : serde_json :: Value = response
. json ()
.await
. map_err ( | e | format\ ! ( "レスポンスのJSONパースに失敗: {}" , e)) ? ;
// セーフティフィルターによるブロックを確認
if let Some (reason) = json[ "promptFeedback" ][ "blockReason" ] . as_str () {
return Err (format\ ! ( "コンテンツがブロックされました: {}" , reason));
}
let text = json[ "candidates" ][ 0 ][ "content" ][ "parts" ][ 0 ][ "text" ]
. as_str ()
. ok_or_else ( || "レスポンスにテキストが含まれていません" . to_string ()) ?
. to_string ();
Ok (text)
}
フロントエンド(TypeScript)からの呼び出し方:
import { invoke } from "@tauri-apps/api/core" ;
async function askGemini ( prompt : string ) : Promise < string > {
try {
// Rustの generate_text コマンドを呼び出す
const response = await invoke < string >( "generate_text" , { prompt });
return response;
} catch (error) {
// Rustから返ってきたエラーメッセージをそのまま表示
throw new Error ( typeof error === "string" ? error : "不明なエラー" );
}
}
ストリーミングレスポンスをリアルタイムで配信する
Gemini APIのストリーミング(Server-Sent Events)をTauriで扱うには、Tauriのイベントシステム (window.emit())を使ってRustからフロントエンドにチャンクを送ります。「Rustがストリームを受け取り → フロントエンドにイベントとして送る」という流れです。
use futures_util :: StreamExt ;
#[tauri :: command]
async fn generate_text_stream (
window : tauri :: Window ,
prompt : String ,
) -> Result <(), String > {
let api_key = get_api_key () ? ;
let client = get_client ();
let url = format\ ! (
"https://generativelanguage.googleapis.com/v1beta/\
models/gemini-2.5-pro-latest:streamGenerateContent?alt=sse&key={}" ,
api_key
);
let request_body = GeminiRequest {
contents : vec\ ! [ GeminiContent {
parts : vec\ ! [ GeminiPart { text : prompt }],
}],
};
let response = client
. post ( & url)
. json ( & request_body)
. send ()
.await
. map_err ( | e | format\ ! ( "リクエスト失敗: {}" , e)) ? ;
if \ ! response . status () . is_success () {
let error = response . text () .await. unwrap_or_default ();
window . emit ( "stream-error" , & error) . ok ();
return Err (error);
}
let mut byte_stream = response . bytes_stream ();
let mut line_buffer = String :: new ();
while let Some (chunk) = byte_stream . next () .await {
match chunk {
Ok (bytes) => {
line_buffer . push_str ( & String :: from_utf8_lossy ( & bytes));
// SSEは改行でイベントを区切る。バッファから完全な行だけを処理する
while let Some (newline_pos) = line_buffer . find ( ' \n ' ) {
let line = line_buffer[ .. newline_pos] . trim () . to_string ();
line_buffer = line_buffer[newline_pos + 1 .. ] . to_string ();
if let Some (data) = line . strip_prefix ( "data: " ) {
if data == "[DONE]" {
window . emit ( "stream-done" , ()) . ok ();
return Ok (());
}
if let Ok (json) = serde_json :: from_str :: < serde_json :: Value >(data) {
if let Some (text) = json[ "candidates" ][ 0 ][ "content" ][ "parts" ][ 0 ][ "text" ] . as_str () {
if \ ! text . is_empty () {
window . emit ( "stream-chunk" , text) . ok ();
}
}
}
}
}
}
Err (e) => {
let msg = e . to_string ();
window . emit ( "stream-error" , & msg) . ok ();
return Err (msg);
}
}
}
window . emit ( "stream-done" , ()) . ok ();
Ok (())
}
フロントエンド側でイベントを受け取るカスタムフック:
// src/hooks/useGeminiStream.ts
import { invoke } from "@tauri-apps/api/core" ;
import { listen, type UnlistenFn } from "@tauri-apps/api/event" ;
import { useState, useCallback } from "react" ;
export function useGeminiStream () {
const [ response , setResponse ] = useState ( "" );
const [ isStreaming , setIsStreaming ] = useState ( false );
const [ error , setError ] = useState < string | null >( null );
const sendMessage = useCallback (
async ( prompt : string ) => {
if (\ ! prompt. trim () || isStreaming) return ;
setResponse ( "" );
setError ( null );
setIsStreaming ( true );
const unlisteners : UnlistenFn [] = [];
try {
// ⚠️ イベントリスナーを invoke() より前に設定する
// 後から設定すると最初のチャンクを取りこぼす
unlisteners. push (
await listen < string >( "stream-chunk" , ( event ) => {
setResponse (( prev ) => prev + event.payload);
})
);
unlisteners. push (
await listen ( "stream-done" , () => {
setIsStreaming ( false );
})
);
unlisteners. push (
await listen < string >( "stream-error" , ( event ) => {
setError ( `ストリームエラー: ${ event . payload }` );
setIsStreaming ( false );
})
);
await invoke ( "generate_text_stream" , { prompt });
} catch (err) {
setError (err instanceof Error ? err.message : String (err));
setIsStreaming ( false );
} finally {
// 必ずリスナーを解除してメモリリークを防ぐ
unlisteners. forEach (( fn ) => fn ());
}
},
[isStreaming]
);
const reset = useCallback (() => {
setResponse ( "" );
setError ( null );
}, []);
return { response, isStreaming, error, sendMessage, reset };
}
このフックを使ったチャットUIの実装:
// src/App.tsx
import { useState } from "react" ;
import { useGeminiStream } from "./hooks/useGeminiStream" ;
export default function App () {
const [ prompt , setPrompt ] = useState ( "" );
const { response , isStreaming , error , sendMessage , reset } = useGeminiStream ();
const handleSubmit = async ( e : React . FormEvent ) => {
e. preventDefault ();
if (\ ! prompt. trim ()) return ;
await sendMessage (prompt);
setPrompt ( "" );
};
return (
< div className = "app" >
< div className = "response-area" >
{ error ? (
< div className = "error" > {error} </ div >
) : (
< div className = "response" >
{ response }
{ isStreaming && < span className = "cursor" > ▋ </ span > }
</ div >
)}
</ div >
< form onSubmit = {handleSubmit} >
< textarea
value = {prompt}
onChange = {(e) => setPrompt (e.target.value)}
placeholder = "Geminiに質問してみてください..."
disabled = {isStreaming}
onKeyDown = {(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
handleSubmit (e);
}
}}
/>
< div className = "buttons" >
< button type = "submit" disabled = {isStreaming || \!prompt.trim()} >
{ isStreaming ? "生成中..." : "送信 (⌘+Enter)" }
</ button >
< button type = "button" onClick = {reset} disabled = {isStreaming} >
クリア
</ button >
</ div >
</ form >
</ div >
);
}
デスクトップネイティブ機能との連携
Webアプリとの最大の差別化ポイントが、クリップボード・通知・ファイルシステムなどのOS機能との連携です。まずsrc-tauri/capabilities/default.jsonに必要な権限を追加します:
{
"identifier" : "default" ,
"description" : "デフォルト権限" ,
"windows" : [ "main" ],
"permissions" : [
"core:default" ,
"clipboard-manager:allow-read-text" ,
"clipboard-manager:allow-write-text" ,
"notification:allow-send-notification" ,
"shell:allow-open"
]
}
クリップボードのテキストをGeminiで要約する実装例 :
import { readText, writeText } from "@tauri-apps/plugin-clipboard-manager" ;
import { sendNotification } from "@tauri-apps/plugin-notification" ;
import { invoke } from "@tauri-apps/api/core" ;
async function summarizeClipboard () {
const clipboardText = await readText ();
if (\ ! clipboardText?. trim ()) {
await sendNotification ({
title: "Gemini AI" ,
body: "クリップボードにテキストがありません。" ,
});
return ;
}
const prompt = `以下のテキストを要点3つに絞って箇条書きで要約してください: \n\n ${ clipboardText }` ;
try {
const summary = await invoke < string >( "generate_text" , { prompt });
// 要約結果をクリップボードに書き戻す
await writeText (summary);
await sendNotification ({
title: "Gemini AI — 要約完了" ,
body: "結果をクリップボードにコピーしました。" ,
});
} catch (err) {
await sendNotification ({
title: "Gemini AI — エラー" ,
body: String (err),
});
}
}
このパターンを使うと、グローバルキーボードショートカット(Tauriのtauri-plugin-global-shortcut)と組み合わせて、「何かを選択してショートカットを押すと即座にAI処理」という、アプリ独立した体験が作れます。Webアプリではできない、デスクトップアプリならではの価値です。
よくある落とし穴と解決策
落とし穴1:ストリームの最初のチャンクを取りこぼす
フロントエンドでinvoke("generate_text_stream")を呼んだ直後にイベントリスナーを設定すると、Rustがすでに送り始めた最初のチャンクを受け取れません。必ずリスナーを先に設定してからinvoke()を呼ぶ順序を守ってください。上記のカスタムフックではawait listen()を先に実行する設計になっています。
落とし穴2:reqwestのfeatureフラグ不足
JSONレスポンスとストリーミングの両方を使う場合、features = ["json", "stream"]の両方がCargo.tomlに必要です。streamを付け忘れるとbytes_stream()がコンパイルエラーになります。また、Tauri 2.0ではreqwestのblockingフィーチャーは使えません(Tokioランタイムとの相性問題のため)。
落とし穴3:Capabilities設定の漏れ
Tauri 2.0では、プラグインを使う際にsrc-tauri/capabilities/フォルダで明示的な権限が必要です。プラグインをCargo.tomlに追加しただけでは使えず、capabilities/default.jsonにも対応する権限を追加する必要があります。「機能が呼び出せない」「permissionエラーが出る」場合は、まずここを確認してください。
落とし穴4:クロスコンパイルは原則できない
macOSからWindowsバイナリを直接ビルドしようとしても、多くの場合うまくいきません。マルチプラットフォーム対応には、GitHub ActionsでOS別のランナーを使うCI/CDが最も現実的です:
# .github/workflows/release.yml(主要部分)
jobs :
build :
strategy :
matrix :
os : [ windows-latest , macos-14 , ubuntu-22.04 ]
runs-on : ${{ matrix.os }}
steps :
- uses : actions/checkout@v4
- uses : dtolnay/rust-toolchain@stable
- run : npm ci
- run : npm run tauri build
落とし穴5:Windowsでのwindows_subsystem = "windows"の扱い
main.rsの冒頭に#\![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]を入れておかないと、本番ビルドのWindowsアプリ起動時にコンソールウィンドウが表示されます。上記のコードにはすでに含まれていますが、自分でmain.rsを書き直す際は忘れずに追加してください。
本番ビルドとコード署名
開発中はnpm run tauri devでホットリロードしながら開発できます。本番ビルドは以下のコマンドで生成されます:
npm run tauri build
# ビルド成果物の場所
# macOS: src-tauri/target/release/bundle/dmg/*.dmg
# src-tauri/target/release/bundle/macos/*.app
# Windows: src-tauri/target/release/bundle/msi/*.msi
# src-tauri/target/release/bundle/nsis/*.exe
# Linux: src-tauri/target/release/bundle/deb/*.deb
# src-tauri/target/release/bundle/rpm/*.rpm
macOSのコード署名と公証 について。Apple Developer Program(年間$99)に加入している場合、tauri.conf.jsonのbundle.macOSセクションにsigningIdentityとproviderShortNameを設定するだけで、tauri build実行時に自動的にコード署名と公証が行われます。公証なしで配布すると、Gatekeeperが「悪意のあるソフトウェア」として警告を出し、一般ユーザーには起動できません。
Windowsは開発者向け配布であれば署名なしでも動きますが、SmartScreen警告が表示されます。法人向け配布を想定する場合はEV証明書の取得を検討してください。
Gemini APIのストリーミングに関する詳細な実装パターンについては、Gemini APIでストリーミング応答とマルチターン会話を実装する も参考になります。Function Callingと組み合わせることで、より高機能なデスクトップAIエージェントも実現できます(Gemini API Function Calling 完全入門ガイド も合わせてご覧ください)。
個人開発者の視点から(実体験メモ)
全体を振り返って
Tauri 2.0 × Gemini APIの組み合わせは、「APIキーをどこで管理するか」という問題をRustバックエンドで解決しながら、ElectronよりはるかにコンパクトなデスクトップAIアプリが作れる構成です。ストリーミングはTauriのイベントシステムを使えば想像より素直に実装でき、クリップボードや通知との連携でWebアプリにはできない体験が作れます。
まず動かすための最短ルートとして、このガイドのRustコードとReactフックをそのまま使って、ストリーミングが動くことを確認するところから始めてみてください。
Electron を選ぶ理由——PWA・モバイルアプリとの使い分け
Gemini API を使ったアプリの選択肢として、PWA(Progressive Web App)、モバイルアプリ(React Native・Flutter)、そして Electron があります。それぞれ得意な場面が異なります。
PWA は Web ブラウザ上で動くため、配布が簡単でアップデートもサーバー側のみで済みます。ただし、ローカルファイルシステムへのアクセスは制限があり、「フォルダ全体を読み込んで AI に分析させる」ような処理は難しいです。モバイルアプリは iOS・Android 向けですが、「Mac の作業デスクトップで使いたい」という用途には合いません。
Electron が強いのは以下の場面です。
ローカルファイル・フォルダの読み書き が必要な AI ツール(PDF 一括分析、コードベース解析など)
システムトレイ常駐型 のアシスタントアプリ
ネイティブ OS 機能 (通知、クリップボード、ショートカットキー登録)を活用したいとき
エンジニア以外のユーザーに配布したいデスクトップ完結型ツール
npm install なしでダブルクリックで使える形にしたいなら、Electron はいまも有力な選択肢です。詳細な比較は Gemini API × PWA 完全実装ガイド も参考にしてください。
プロジェクトのセットアップと基本構造
まず Electron + TypeScript + Vite を組み合わせたテンプレートを使います。electron-vite が一番手軽です。
# プロジェクト作成
npm create electron-vite@latest gemini-desktop -- --template react-ts
cd gemini-desktop
npm install
npm install @google/genai keytar electron-store
npm install -D @types/node
@google/genai は Google AI の公式 JS/TS SDK、keytar は OS のキーチェーンに API キーを安全に保存するライブラリ、electron-store はアプリ設定の永続化に使います。
プロジェクト構造は以下のようになります。
gemini-desktop/
├── src/
│ ├── main/
│ │ ├── index.ts ← メインプロセス(Gemini API 呼び出しはここだけ)
│ │ ├── gemini.ts ← Gemini API ラッパー
│ │ └── ipc-handlers.ts ← IPC ハンドラー定義
│ ├── preload/
│ │ └── index.ts ← プリロードスクリプト(安全な IPC ブリッジ)
│ └── renderer/
│ └── src/
│ └── App.tsx ← UI(Gemini API は直接呼ばない)
├── electron.vite.config.ts
└── package.json
この構造で最重要なのは「Gemini API を呼び出すコードはすべてメインプロセス(src/main/)に置く 」という原則です。なぜこれが重要かを次のセクションで詳しく説明します。
セキュアな API キー管理——最初の設計判断がすべてを決める
Electron のセキュリティ事故で最も多いパターンが、API キーをレンダラープロセス(Web コンテンツが動く側)から直接 Gemini API に送っているケースです。レンダラープロセスは本質的に「ブラウザのタブ」と同じで、DevTools からコードをインスペクトできます。
絶対に避けるべきパターン(レンダラーに API キーを置いてはいけない) :
// ❌ NG: renderer/src/App.tsx
import { GoogleGenAI } from '@google/genai' ;
const ai = new GoogleGenAI ({ apiKey: 'AIza...' }); // キーが露出
正しいパターン(メインプロセスのみが API キーを持つ) :
// ✅ main/gemini.ts
import { GoogleGenAI } from '@google/genai' ;
import keytar from 'keytar' ;
const SERVICE_NAME = 'gemini-desktop' ;
const ACCOUNT_NAME = 'gemini-api-key' ;
export async function getGenAI () : Promise < GoogleGenAI | null > {
const apiKey = await keytar. getPassword ( SERVICE_NAME , ACCOUNT_NAME );
if ( ! apiKey) return null ;
return new GoogleGenAI ({ apiKey });
}
export async function saveApiKey ( apiKey : string ) : Promise < void > {
await keytar. setPassword ( SERVICE_NAME , ACCOUNT_NAME , apiKey);
}
export async function deleteApiKey () : Promise < void > {
await keytar. deletePassword ( SERVICE_NAME , ACCOUNT_NAME );
}
keytar は macOS なら Keychain、Windows なら Credential Manager、Linux なら libsecret を使います。平文で electron-store に保存する方法も見かけますが、ユーザーのホームディレクトリにある JSON ファイルに API キーを書くのは推奨しません。
次に、プリロードスクリプトでレンダラーに安全な「ブリッジ」を公開します。
// preload/index.ts
import { contextBridge, ipcRenderer } from 'electron' ;
contextBridge. exposeInMainWorld ( 'geminiAPI' , {
// API キーの保存・取得はメインプロセス経由のみ
saveApiKey : ( key : string ) => ipcRenderer. invoke ( 'save-api-key' , key),
checkApiKey : () => ipcRenderer. invoke ( 'check-api-key' ),
// チャット送信もメインプロセス経由
sendMessage : ( message : string , history : unknown []) =>
ipcRenderer. invoke ( 'send-message' , message, history),
// ストリーミング受信
onStreamChunk : ( callback : ( chunk : string ) => void ) => {
ipcRenderer. on ( 'stream-chunk' , ( _event , chunk ) => callback (chunk));
return () => ipcRenderer. removeAllListeners ( 'stream-chunk' );
},
});
contextBridge.exposeInMainWorld を使うことで、レンダラーは window.geminiAPI 経由で操作できますが、ipcRenderer 自体や Node.js の API には触れられません。
Gemini API ストリーミングチャットの実装
メインプロセスの IPC ハンドラーでストリーミングを実装します。Electron ではレンダラーへのリアルタイムプッシュに webContents.send() を使います。
// main/ipc-handlers.ts
import { ipcMain, BrowserWindow } from 'electron' ;
import { getGenAI } from './gemini' ;
export function registerIpcHandlers ( mainWindow : BrowserWindow ) : void {
ipcMain. handle ( 'check-api-key' , async () => {
const ai = await getGenAI ();
return ai !== null ;
});
ipcMain. handle ( 'save-api-key' , async ( _event , apiKey : string ) => {
const { saveApiKey } = await import ( './gemini' );
await saveApiKey (apiKey);
return { success: true };
});
ipcMain. handle ( 'send-message' , async ( _event , userMessage : string , history : Array <{ role : string ; parts : string }>) => {
const ai = await getGenAI ();
if ( ! ai) {
return { error: 'API key not configured' };
}
try {
// 会話履歴を Google AI SDK の形式に変換
const contents = history. map ( h => ({
role: h.role as 'user' | 'model' ,
parts: [{ text: h.parts }],
}));
contents. push ({ role: 'user' , parts: [{ text: userMessage }] });
// ストリーミングで送信
const result = await ai.models. generateContentStream ({
model: 'gemini-2.5-flash' ,
contents,
});
let fullResponse = '' ;
for await ( const chunk of result) {
const text = chunk.text ?? '' ;
if (text) {
fullResponse += text;
// レンダラーへリアルタイム送信
mainWindow.webContents. send ( 'stream-chunk' , text);
}
}
// ストリーム終了シグナル
mainWindow.webContents. send ( 'stream-chunk' , '__DONE__' );
return { success: true , fullText: fullResponse };
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error' ;
return { error: message };
}
});
}
レンダラー側の React コンポーネントはこうなります。
// renderer/src/App.tsx
import { useState, useEffect, useRef } from 'react' ;
interface Message {
role : 'user' | 'model' ;
content : string ;
}
export default function App () {
const [ messages , setMessages ] = useState < Message []>([]);
const [ input , setInput ] = useState ( '' );
const [ streaming , setStreaming ] = useState ( false );
const streamBufferRef = useRef ( '' );
useEffect (() => {
// ストリームチャンクの受信
const cleanup = window.geminiAPI. onStreamChunk (( chunk : string ) => {
if (chunk === '__DONE__' ) {
// ストリーム完了
setStreaming ( false );
streamBufferRef.current = '' ;
} else {
streamBufferRef.current += chunk;
// モデルの最後のメッセージをリアルタイム更新
setMessages ( prev => {
const newMessages = [ ... prev];
const last = newMessages[newMessages. length - 1 ];
if (last?.role === 'model' ) {
newMessages[newMessages. length - 1 ] = {
... last,
content: streamBufferRef.current,
};
}
return newMessages;
});
}
});
return cleanup;
}, []);
const handleSend = async () => {
if ( ! input. trim () || streaming) return ;
const userMessage = input. trim ();
setInput ( '' );
setStreaming ( true );
// ユーザーメッセージを追加し、モデルの空メッセージを予約
setMessages ( prev => [
... prev,
{ role: 'user' , content: userMessage },
{ role: 'model' , content: '' },
]);
const history = messages. map ( m => ({
role: m.role,
parts: m.content,
}));
const result = await window.geminiAPI. sendMessage (userMessage, history);
if (result.error) {
setStreaming ( false );
console. error (result.error);
}
};
return (
< div className = "chat-container" >
< div className = "messages" >
{ messages. map (( msg , i ) => (
< div key = { i } className = { `message ${ msg . role }` } >
< span className = "role" > { msg.role === 'user' ? 'You' : 'Gemini' } </ span >
< p > { msg.content } </ p >
</ div >
)) }
</ div >
< div className = "input-area" >
< textarea
value = { input }
onChange = { e => setInput (e.target.value) }
onKeyDown = { e => { if (e.key === 'Enter' && ! e.shiftKey) { e. preventDefault (); handleSend (); } } }
placeholder = "メッセージを入力(Shift+Enter で改行)"
disabled = { streaming }
/>
< button onClick = { handleSend } disabled = { streaming } >
{ streaming ? '送信中...' : '送信' }
</ button >
</ div >
</ div >
);
}
ストリーミングの実装で私が最初に詰まったのは、webContents.send() のタイミングです。BrowserWindow が生成される前に registerIpcHandlers() を呼ぶと webContents が undefined になります。app.whenReady() の中でウィンドウを作ってからハンドラー登録、という順序を守ってください。
Function Calling でローカル OS リソースを操作する
Electron の大きな強みは OS へのフルアクセスです。Gemini API の Function Calling と組み合わせると、「このフォルダにある画像をすべてリサイズして」という自然言語指示をそのまま実行できる AI エージェントを作れます。
// main/ipc-handlers.ts(Function Calling 部分)
import { Tool } from '@google/genai' ;
import fs from 'fs/promises' ;
import path from 'path' ;
const localTools : Tool [] = [
{
functionDeclarations: [
{
name: 'list_files' ,
description: '指定したディレクトリ内のファイル一覧を取得する' ,
parameters: {
type: 'OBJECT' ,
properties: {
directory: {
type: 'STRING' ,
description: '対象ディレクトリの絶対パス' ,
},
extension: {
type: 'STRING' ,
description: '拡張子フィルター(例: .txt, .png)。省略時は全ファイル' ,
},
},
required: [ 'directory' ],
},
},
{
name: 'read_file' ,
description: 'テキストファイルの内容を読み込む' ,
parameters: {
type: 'OBJECT' ,
properties: {
file_path: {
type: 'STRING' ,
description: 'ファイルの絶対パス' ,
},
},
required: [ 'file_path' ],
},
},
],
},
];
// ツール実行関数
async function executeTool (
name : string ,
args : Record < string , string >
) : Promise < string > {
switch (name) {
case 'list_files' : {
const entries = await fs. readdir (args.directory, { withFileTypes: true });
const files = entries
. filter ( e => e. isFile ())
. map ( e => e.name)
. filter ( n => ! args.extension || n. endsWith (args.extension));
return JSON . stringify ({ files, count: files. length });
}
case 'read_file' : {
// セキュリティ: パストラバーサル対策
const resolved = path. resolve (args.file_path);
const content = await fs. readFile (resolved, 'utf-8' );
// 大きなファイルは先頭 4000 文字に制限
return content. length > 4000
? content. slice ( 0 , 4000 ) + ' \n [...ファイルが長いため省略...]'
: content;
}
default :
return JSON . stringify ({ error: `Unknown tool: ${ name }` });
}
}
// Function Calling ループを含む IPC ハンドラー
ipcMain. handle ( 'send-agent-message' , async ( _event , userMessage : string ) => {
const ai = await getGenAI ();
if ( ! ai) return { error: 'API key not configured' };
const contents : Array <{ role : 'user' | 'model' ; parts : Array <{ text ?: string ; functionCall ?: unknown ; functionResponse ?: unknown }> }> = [
{ role: 'user' , parts: [{ text: userMessage }] },
];
// Function Calling エージェントループ(最大5回)
for ( let i = 0 ; i < 5 ; i ++ ) {
const response = await ai.models. generateContent ({
model: 'gemini-2.5-flash' ,
contents,
config: { tools: localTools },
});
const candidate = response.candidates?.[ 0 ];
if ( ! candidate) break ;
const hasFunctionCall = candidate.content.parts. some ( p => p.functionCall);
if ( ! hasFunctionCall) {
// 最終テキスト応答
const text = candidate.content.parts. find ( p => p.text)?.text ?? '' ;
mainWindow.webContents. send ( 'stream-chunk' , text);
mainWindow.webContents. send ( 'stream-chunk' , '__DONE__' );
return { success: true };
}
// Function Call の実行
contents. push ({ role: 'model' , parts: candidate.content.parts });
const toolResults = [];
for ( const part of candidate.content.parts) {
if ( ! part.functionCall) continue ;
const fc = part.functionCall as { name : string ; args : Record < string , string > };
const result = await executeTool (fc.name, fc.args);
toolResults. push ({
functionResponse: {
name: fc.name,
response: { output: result },
},
});
}
contents. push ({ role: 'user' , parts: toolResults });
}
return { error: 'Max function call iterations reached' };
});
Function Calling を実装するとき、パストラバーサル対策 は必須です。ユーザーが「../../../../etc/passwd を読んで」と指示した場合でも、path.resolve() でパスを正規化した上で、許可されたディレクトリ内かどうかを確認するホワイトリスト検証を追加することをおすすめします。詳細な Function Calling の実装パターンは Gemini API Function Calling 完全ガイド を参照してください。
マルチモーダル入力——ローカルファイルを Gemini API に渡す
Electron のもう一つの強みは、Drag & Drop でローカル画像や PDF をアプリに渡せることです。Web アプリでは <input type="file"> が必要ですが、Electron ではウィンドウ全体へのドラッグを受け付けられます。
ローカルファイルを Gemini API に渡すときは、ファイルを base64 エンコードしてインラインデータとして送ります(小さいファイルの場合)。ファイルが大きい場合は File API を使います。
// main/gemini.ts(ファイル処理部分)
import fs from 'fs/promises' ;
import path from 'path' ;
interface FileAnalysisRequest {
filePath : string ;
prompt : string ;
}
export async function analyzeLocalFile (
req : FileAnalysisRequest ,
ai : GoogleGenAI
) : Promise < string > {
const filePath = path. resolve (req.filePath);
const fileBuffer = await fs. readFile (filePath);
const ext = path. extname (filePath). toLowerCase ();
// MIME タイプの解決
const mimeMap : Record < string , string > = {
'.jpg' : 'image/jpeg' ,
'.jpeg' : 'image/jpeg' ,
'.png' : 'image/png' ,
'.webp' : 'image/webp' ,
'.gif' : 'image/gif' ,
'.pdf' : 'application/pdf' ,
};
const mimeType = mimeMap[ext];
if ( ! mimeType) {
throw new Error ( `Unsupported file type: ${ ext }` );
}
// 10MB 未満はインライン base64、以上は File API 推奨
const INLINE_LIMIT = 10 * 1024 * 1024 ;
if (fileBuffer. length < INLINE_LIMIT ) {
const base64Data = fileBuffer. toString ( 'base64' );
const response = await ai.models. generateContent ({
model: 'gemini-2.5-flash' ,
contents: [
{
role: 'user' ,
parts: [
{
inlineData: {
mimeType,
data: base64Data,
},
},
{ text: req.prompt },
],
},
],
});
return response.text ?? '' ;
} else {
// File API を使ったアップロード(大きなファイル向け)
const uploadedFile = await ai.files. upload ({
file: new Blob ([fileBuffer], { type: mimeType }),
config: { mimeType, displayName: path. basename (filePath) },
});
if ( ! uploadedFile.uri) throw new Error ( 'File upload failed' );
const response = await ai.models. generateContent ({
model: 'gemini-2.5-flash' ,
contents: [
{
role: 'user' ,
parts: [
{ fileData: { mimeType, fileUri: uploadedFile.uri } },
{ text: req.prompt },
],
},
],
});
return response.text ?? '' ;
}
}
この実装で私が見落としていた点が一つあります。ai.files.upload() は非同期でサーバーにアップロードされますが、大きなファイルは状態が PROCESSING のまましばらく続きます。response.candidates が空になるケースがあるので、本番では file.state が ACTIVE になるまでポーリングする処理が必要です。
オフライン検出・エラーハンドリング・リトライ設計
デスクトップアプリはネットワーク接続が不安定な状況でも使われます。「Gemini API が 503 を返した」「Wi-Fi が切れた」という状況を適切にハンドリングしないと、ユーザーはアプリが固まったと判断してプロセスを強制終了します。
// main/ipc-handlers.ts(エラーハンドリング強化版)
import { net } from 'electron' ;
async function isOnline () : Promise < boolean > {
return net. isOnline ();
}
async function withRetry < T >(
fn : () => Promise < T >,
maxRetries = 3 ,
baseDelayMs = 1000
) : Promise < T > {
let lastError : Error | undefined ;
for ( let attempt = 0 ; attempt < maxRetries; attempt ++ ) {
try {
return await fn ();
} catch (error) {
lastError = error instanceof Error ? error : new Error ( String (error));
// リトライ不要なエラーは即座に throw
const message = lastError.message. toLowerCase ();
if (
message. includes ( 'api key' ) ||
message. includes ( 'permission denied' ) ||
message. includes ( 'billing' )
) {
throw lastError;
}
// 指数バックオフ
if (attempt < maxRetries - 1 ) {
const delay = baseDelayMs * Math. pow ( 2 , attempt);
await new Promise ( resolve => setTimeout (resolve, delay));
mainWindow.webContents. send ( 'status' , `リトライ中... (${ attempt + 2 }/${ maxRetries })` );
}
}
}
throw lastError ?? new Error ( 'Max retries exceeded' );
}
ipcMain. handle ( 'send-message' , async ( _event , userMessage : string , history : Array <{ role : string ; parts : string }>) => {
if ( ! ( await isOnline ())) {
return { error: 'インターネット接続を確認してください。' };
}
return withRetry ( async () => {
const ai = await getGenAI ();
if ( ! ai) return { error: 'API キーが設定されていません。' };
// ... 送信処理
});
});
net.isOnline() は Electron のネイティブ API で、navigator.onLine よりも信頼性が高いです。レート制限については Gemini API レート制限・クォータ管理 と Gemini API コスト最適化完全ガイド も参照してください。
electron-builder によるパッケージング・配布・自動更新
動くアプリができたら、次は配布です。electron-builder を使えば macOS(.dmg)と Windows(.exe)のインストーラーを生成できます。
npm install -D electron-builder
package.json に設定を追加します。
{
"build" : {
"appId" : "net.dolice.gemini-desktop" ,
"productName" : "Gemini Desktop" ,
"directories" : {
"output" : "dist-installer"
},
"mac" : {
"category" : "public.app-category.productivity" ,
"target" : [
{ "target" : "dmg" , "arch" : [ "arm64" , "x64" ] }
],
"hardenedRuntime" : true ,
"entitlements" : "entitlements.mac.plist" ,
"entitlementsInherit" : "entitlements.mac.plist"
},
"win" : {
"target" : [
{ "target" : "nsis" , "arch" : [ "x64" ] }
]
},
"nsis" : {
"oneClick" : false ,
"allowToChangeInstallationDirectory" : true
},
"publish" : [
{
"provider" : "github" ,
"owner" : "your-github-username" ,
"repo" : "gemini-desktop"
}
]
}
}
macOS でアプリを配布する場合、コード署名(Code Signing)と公証(Notarization) が必須です。Apple Developer Program への加入(年間 $99)と、環境変数への証明書設定が必要になります。
# macOS 向けビルド
CSC_LINK = ~/.electron/certs/your-cert.p12 \
CSC_KEY_PASSWORD=yourpassword \
APPLE_ID=you@example.com \
APPLE_ID_PASSWORD=your-app-specific-password \
APPLE_TEAM_ID=XXXXXXXXXX \
npm run build:mac
自動更新は electron-updater で実装できます。GitHub Releases にビルドをアップロードすれば、アプリ起動時に新バージョンを自動チェックして通知できます。
// main/index.ts(自動更新)
import { autoUpdater } from 'electron-updater' ;
autoUpdater.autoDownload = false ;
autoUpdater. checkForUpdatesAndNotify ();
autoUpdater. on ( 'update-available' , ( info ) => {
mainWindow.webContents. send ( 'update-available' , info.version);
});
autoUpdater. on ( 'update-downloaded' , () => {
mainWindow.webContents. send ( 'update-ready' );
});
// IPC でインストール実行
ipcMain. handle ( 'install-update' , () => {
autoUpdater. quitAndInstall ();
});
よくある間違いと落とし穴
落とし穴 1: nodeIntegration: true を設定してしまう
古いチュートリアルや Stack Overflow の回答には nodeIntegration: true を設定するものがあります。これをすると、レンダラープロセスから Node.js の全 API にアクセスできてしまい、XSS 攻撃が発生した際の被害が極めて大きくなります。現代の Electron では nodeIntegration: false(デフォルト)+ contextBridge が正解です。
// ❌ 絶対にやってはいけない
new BrowserWindow ({
webPreferences: {
nodeIntegration: true , // 危険
contextIsolation: false , // 危険
},
});
// ✅ 正しい設定
new BrowserWindow ({
webPreferences: {
nodeIntegration: false ,
contextIsolation: true ,
preload: path. join (__dirname, 'preload.js' ),
},
});
落とし穴 2: メインプロセスの unhandledRejection が無視される
レンダラーと違い、メインプロセスのエラーはデフォルトではクラッシュリポートに送られません。process.on('unhandledRejection', ...) を設定しないと、Gemini API の呼び出しが静かに失敗し続けます。
// main/index.ts
process. on ( 'unhandledRejection' , ( reason , promise ) => {
console. error ( 'Unhandled Rejection at:' , promise, 'reason:' , reason);
// 必要に応じてクラッシュレポートを送信
});
落とし穴 3: electron-store に機密情報を保存する
electron-store は便利ですが、保存先は ~/Library/Application Support/<app-name>/config.json(macOS)です。ファイルシステムにアクセスできる人なら誰でも読めます。API キーなどの機密情報は必ず keytar でキーチェーンに保存してください。ユーザー設定(テーマ、モデル選択、チャット履歴など機密でないもの)は electron-store で構いません。
落とし穴 4: Vite のホットリロードと IPC ハンドラーの重複登録
electron-vite の開発モードでは、ファイル変更のたびにメインプロセスが再起動します。ipcMain.handle() は同じチャンネル名を二重登録するとエラーになります。開発中にこのエラーが頻発する場合、ハンドラー登録前に ipcMain.removeHandler() を呼ぶか、フラグで管理してください。
落とし穴 5: File API の PROCESSING 状態を無視する
大きなファイル(PDF や動画)を ai.files.upload() でアップロードすると、状態が PROCESSING のままになります。このファイルを使って generateContent() を呼ぶと空の応答が返ることがあります。ACTIVE になるまで 1〜5 秒ポーリングする処理を入れましょう。
async function waitForFileActive (
ai : GoogleGenAI ,
file : { name : string ; state ?: string }
) : Promise < void > {
let currentFile = file;
while (currentFile.state === 'PROCESSING' ) {
await new Promise ( r => setTimeout (r, 2000 ));
currentFile = await ai.files. get ({ name: file.name });
}
if (currentFile.state !== 'ACTIVE' ) {
throw new Error ( `File processing failed: ${ currentFile . state }` );
}
}
ここから先の一歩
まずは npm create electron-vite@latest でプロジェクトを作り、API キーの保存と最初のチャットを動かすところから始めてみてください。メインプロセスに API 呼び出しを集約するというアーキテクチャさえ守れば、あとはウェブアプリの開発と変わりません。
本記事のコードはすべて TypeScript で書いていますが、型のつけ方に迷う場合は Gemini API TypeScript 型安全設計ガイド も参考にしてください。
デスクトップアプリは配布や更新の手間がありますが、「自分のファイルを扱える AI ツール」という体験は、Web アプリでは代替できない価値があります。読んでくださった時間が、何か形になることを願っています。