取り組みの背景:なぜデスクトップAIアプリに Electron を選ぶのか
Webブラウザ上でClaude APIを呼び出すアプリは比較的簡単に作れます。しかし、ネイティブのデスクトップ体験・ローカルファイルへのアクセス・オフライン対応・プレミアム感のある配布形態を実現したい場合、Electronは依然として最有力の選択肢です。
2026年現在、デスクトップAIアプリ市場は急速に拡大しています。Claude APIを内蔵したElectronアプリで、ライティングアシスタント・コード補完ツール・ドキュメント解析アシスタントなどを個人開発して収益化しているインディー開発者が増えています。
このガイドでは、Electron × Claude API の本番アプリケーションを構築する際に必ず直面する以下の課題を、実装コードとともに解決します。
- APIキーをユーザーのマシンに安全に保管する方法
- ブラウザとは異なるElectronのプロセス構造でClaude APIのストリーミングを実装する方法
- オフライン時でもクラッシュしない堅牢な設計
- electron-updaterで自動アップデートを実現する方法
- 試用版・有料版の導線設計とサブスクリプション課金の実装
環境構築:Electron × Claude API プロジェクトのセットアップ
推奨スタック
本番グレードのElectron × Claude APIアプリには、以下の構成を推奨します。
- Electron: v33以降(Node.js 22 LTS推奨)
- electron-builder: パッケージング・署名・配布
- electron-updater: 自動アップデート(electron-builderに同梱)
- @anthropic-ai/sdk: Claude API公式SDK
- keytar: ネイティブKeychain/Credential Storeへのアクセス
- Vite + React + TypeScript: Rendererプロセス
- Electron Store: アプリ設定の永続化
プロジェクト初期化
# electron-vite を使った推奨テンプレート
npm create @quick-start/electron@latest claude-desktop-app -- --template react-ts
cd claude-desktop-app
npm install
npm install @anthropic-ai/sdk keytar electron-store
npm install -D electron-builder electron-updater
package.json の主要設定:
{
"name": "claude-desktop-app",
"version": "1.0.0",
"main": "dist/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"release": "npm run build && electron-builder"
},
"build": {
"appId": "net.dolice.claude-desktop",
"productName": "Claude Desktop AI",
"mac": {
"category": "public.app-category.productivity",
"hardenedRuntime": true,
"entitlements": "build/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.plist"
},
"win": {
"target": "nsis"
},
"publish": {
"provider": "github",
"owner": "your-github-username",
"repo": "claude-desktop-app"
}
}
}
セキュアなAPIキー管理:keytar と Electron の IPC
Electronアプリの最大の落とし穴の一つがAPIキーの管理です。環境変数に埋め込む、アプリバンドルに含める — いずれも危険です。本番アプリでは必ず keytar(OS標準のKeychain/Credential Store)を使用してください。
keytar の仕組み
keytarはOSのネイティブAPIを使用してシークレットを保管します。
- macOS: Keychain
- Windows: Credential Manager
- Linux: libsecret (GNOME Keyring)
Main プロセスでの keytar 実装
keytarはNode.js APIを使うため、Mainプロセスでのみ実行します。Rendererプロセスから直接呼び出してはいけません。
// src/main/keyManager.ts
import keytar from 'keytar'
const SERVICE_NAME = 'Claude Desktop AI'
const ACCOUNT_NAME = 'anthropic-api-key'
export const keyManager = {
async saveApiKey(apiKey: string): Promise<void> {
await keytar.setPassword(SERVICE_NAME, ACCOUNT_NAME, apiKey)
},
async getApiKey(): Promise<string | null> {
return await keytar.getPassword(SERVICE_NAME, ACCOUNT_NAME)
},
async deleteApiKey(): Promise<void> {
await keytar.deletePassword(SERVICE_NAME, ACCOUNT_NAME)
},
async hasApiKey(): Promise<boolean> {
const key = await keytar.getPassword(SERVICE_NAME, ACCOUNT_NAME)
return key !== null && key.length > 0
}
}
IPC ハンドラーの設定(Main プロセス)
// src/main/ipcHandlers.ts
import { ipcMain } from 'electron'
import { keyManager } from './keyManager'
import { claudeService } from './claudeService'
export function registerIpcHandlers(): void {
// APIキーの保存
ipcMain.handle('api-key:save', async (_, apiKey: string) => {
await keyManager.saveApiKey(apiKey)
// Claude APIクライアントを再初期化
await claudeService.initialize(apiKey)
return { success: true }
})
// APIキーの存在確認
ipcMain.handle('api-key:check', async () => {
return { hasKey: await keyManager.hasApiKey() }
})
// APIキーの削除(ログアウト)
ipcMain.handle('api-key:delete', async () => {
await keyManager.deleteApiKey()
claudeService.reset()
return { success: true }
})
}
Preload スクリプト(セキュリティブリッジ)
// src/preload/index.ts
import { contextBridge, ipcRenderer } from 'electron'
// ContextIsolation が有効な状態で安全にAPIを公開
contextBridge.exposeInMainWorld('electronAPI', {
saveApiKey: (apiKey: string) =>
ipcRenderer.invoke('api-key:save', apiKey),
checkApiKey: () =>
ipcRenderer.invoke('api-key:check'),
deleteApiKey: () =>
ipcRenderer.invoke('api-key:delete'),
sendMessage: (params: MessageParams) =>
ipcRenderer.invoke('claude:message', params),
// ストリーミング用イベントリスナー
onStreamChunk: (callback: (chunk: string) => void) => {
const handler = (_: unknown, chunk: string) => callback(chunk)
ipcRenderer.on('stream:chunk', handler)
return () => ipcRenderer.removeListener('stream:chunk', handler)
},
onStreamEnd: (callback: () => void) => {
ipcRenderer.once('stream:end', callback)
},
onStreamError: (callback: (error: string) => void) => {
ipcRenderer.once('stream:error', (_, error) => callback(error))
}
})
contextBridge.exposeInMainWorld により、Rendererプロセスは 厳密に公開されたAPIのみ にアクセスできます。nodeIntegration: false + contextIsolation: true の設定と組み合わせて、セキュリティを確保してください。
ストリーミングレスポンスの実装
Claude APIの大きな利点はストリーミングレスポンスです。Electronでは、Mainプロセスでストリームを受け取り、IPCイベントでRendererプロセスに逐次送信するアーキテクチャが最も安定します。
Claude サービス(Main プロセス)
// src/main/claudeService.ts
import Anthropic from '@anthropic-ai/sdk'
import { BrowserWindow } from 'electron'
interface MessageParams {
messages: Array<{
role: 'user' | 'assistant'
content: string
}>
model?: string
maxTokens?: number
}
class ClaudeService {
private client: Anthropic | null = null
async initialize(apiKey: string): Promise<void> {
this.client = new Anthropic({ apiKey })
// 接続テスト(軽量なリクエストで検証)
await this.client.messages.create({
model: 'claude-haiku-4-5-20251001',
max_tokens: 10,
messages: [{ role: 'user', content: 'ping' }]
})
}
reset(): void {
this.client = null
}
async streamMessage(
params: MessageParams,
webContents: Electron.WebContents
): Promise<void> {
if (!this.client) {
webContents.send('stream:error', 'API key not configured')
return
}
const model = params.model ?? 'claude-sonnet-4-6'
try {
const stream = await this.client.messages.stream({
model,
max_tokens: params.maxTokens ?? 4096,
messages: params.messages
})
// テキストデルタをRendererに逐次送信
for await (const event of stream) {
if (
event.type === 'content_block_delta' &&
event.delta.type === 'text_delta'
) {
webContents.send('stream:chunk', event.delta.text)
}
}
webContents.send('stream:end')
} catch (error) {
const message =
error instanceof Anthropic.APIError
? `API Error ${error.status}: ${error.message}`
: String(error)
webContents.send('stream:error', message)
}
}
}
export const claudeService = new ClaudeService()
IPC ハンドラーにストリーミングを追加
// src/main/ipcHandlers.ts(追記)
ipcMain.handle('claude:message', async (event, params: MessageParams) => {
const webContents = event.sender
await claudeService.streamMessage(params, webContents)
return { started: true }
})
Renderer プロセスでのストリーミング受信
// src/renderer/hooks/useClaudeStream.ts
import { useState, useCallback, useRef } from 'react'
export function useClaudeStream() {
const [response, setResponse] = useState('')
const [isStreaming, setIsStreaming] = useState(false)
const [error, setError] = useState<string | null>(null)
const cleanupRef = useRef<(() => void) | null>(null)
const sendMessage = useCallback(async (messages: Message[]) => {
setResponse('')
setIsStreaming(true)
setError(null)
// 前のリスナーをクリーンアップ
cleanupRef.current?.()
// ストリームチャンクの受信
cleanupRef.current = window.electronAPI.onStreamChunk((chunk) => {
setResponse(prev => prev + chunk)
})
window.electronAPI.onStreamEnd(() => {
setIsStreaming(false)
cleanupRef.current?.()
})
window.electronAPI.onStreamError((err) => {
setError(err)
setIsStreaming(false)
cleanupRef.current?.()
})
await window.electronAPI.sendMessage({ messages })
}, [])
return { response, isStreaming, error, sendMessage }
}
オフラインモードと堅牢なエラーハンドリング
ネットワーク状態に関係なくクラッシュしないアプリ設計は、デスクトップアプリの必須要件です。
ネットワーク状態の監視
// src/main/networkMonitor.ts
import { net } from 'electron'
export class NetworkMonitor {
private listeners: Set<(online: boolean) => void> = new Set()
private _isOnline: boolean = true
constructor() {
this.startMonitoring()
}
private startMonitoring(): void {
// Electronのnet.onlineイベントを使用
const checkOnline = () => {
const wasOnline = this._isOnline
this._isOnline = net.isOnline()
if (wasOnline !== this._isOnline) {
this.listeners.forEach(fn => fn(this._isOnline))
}
}
// 30秒ごとにチェック
setInterval(checkOnline, 30_000)
}
get isOnline(): boolean {
return this._isOnline
}
onChange(callback: (online: boolean) => void): () => void {
this.listeners.add(callback)
return () => this.listeners.delete(callback)
}
}
export const networkMonitor = new NetworkMonitor()
Renderer でのオフライン表示
// src/renderer/components/OfflineBanner.tsx
import { useState, useEffect } from 'react'
export function OfflineBanner() {
const [isOnline, setIsOnline] = useState(navigator.onLine)
useEffect(() => {
const handleOnline = () => setIsOnline(true)
const handleOffline = () => setIsOnline(false)
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
if (isOnline) return null
return (
<div className="offline-banner">
⚠️ オフライン中 — Claude APIに接続できません。
インターネット接続を確認してください。
</div>
)
}
リトライロジックとエクスポネンシャルバックオフ
// src/main/retryUtils.ts
interface RetryOptions {
maxAttempts: number
baseDelayMs: number
maxDelayMs: number
}
export async function withRetry<T>(
fn: () => Promise<T>,
options: RetryOptions = {
maxAttempts: 3,
baseDelayMs: 1000,
maxDelayMs: 10000
}
): Promise<T> {
let lastError: Error
for (let attempt = 1; attempt <= options.maxAttempts; attempt++) {
try {
return await fn()
} catch (error) {
lastError = error as Error
// レート制限(429)はリトライ対象、それ以外のHTTPエラーは即時失敗
if (error instanceof Anthropic.RateLimitError) {
const delay = Math.min(
options.baseDelayMs * 2 ** (attempt - 1),
options.maxDelayMs
)
await new Promise(resolve => setTimeout(resolve, delay))
continue
}
// 4xxエラー(レート制限以外)はリトライしない
if (error instanceof Anthropic.APIError && error.status < 500) {
throw error
}
// 5xxエラーはリトライ
if (attempt < options.maxAttempts) {
const delay = Math.min(
options.baseDelayMs * 2 ** (attempt - 1),
options.maxDelayMs
)
await new Promise(resolve => setTimeout(resolve, delay))
}
}
}
throw lastError!
}
自動アップデートの実装(electron-updater)
ユーザーが常に最新版を使えるよう、自動アップデート機能は欠かせません。electron-builderに同梱された electron-updater を使用します。
Main プロセスでのアップデーター設定
// src/main/updater.ts
import { autoUpdater } from 'electron-updater'
import { BrowserWindow, ipcMain } from 'electron'
import log from 'electron-log'
// アップデーターのログを設定
autoUpdater.logger = log
autoUpdater.autoDownload = false // ユーザーの同意を得てからダウンロード
autoUpdater.autoInstallOnAppQuit = true
export function initUpdater(mainWindow: BrowserWindow): void {
// アップデートチェック(起動後3秒待ってチェック)
setTimeout(() => {
autoUpdater.checkForUpdates().catch(log.error)
}, 3000)
// 以降は1時間ごとにチェック
setInterval(() => {
autoUpdater.checkForUpdates().catch(log.error)
}, 60 * 60 * 1000)
// アップデートが利用可能
autoUpdater.on('update-available', (info) => {
mainWindow.webContents.send('update:available', {
version: info.version,
releaseNotes: info.releaseNotes
})
})
// アップデートなし
autoUpdater.on('update-not-available', () => {
mainWindow.webContents.send('update:not-available')
})
// ダウンロード進捗
autoUpdater.on('download-progress', (progress) => {
mainWindow.webContents.send('update:progress', {
percent: Math.round(progress.percent)
})
})
// ダウンロード完了
autoUpdater.on('update-downloaded', () => {
mainWindow.webContents.send('update:ready')
})
// エラー
autoUpdater.on('error', (error) => {
log.error('Auto update error:', error)
mainWindow.webContents.send('update:error', error.message)
})
// Rendererからのダウンロード指示
ipcMain.handle('update:download', async () => {
await autoUpdater.downloadUpdate()
})
// Rendererからのインストール指示(再起動して適用)
ipcMain.handle('update:install', () => {
autoUpdater.quitAndInstall()
})
}
Renderer でのアップデートUI
// src/renderer/components/UpdateNotification.tsx
import { useState, useEffect } from 'react'
interface UpdateInfo {
version: string
releaseNotes: string
}
export function UpdateNotification() {
const [update, setUpdate] = useState<UpdateInfo | null>(null)
const [progress, setProgress] = useState<number | null>(null)
const [isReady, setIsReady] = useState(false)
useEffect(() => {
window.electronAPI.onUpdateAvailable((info: UpdateInfo) => {
setUpdate(info)
})
window.electronAPI.onUpdateProgress((p: number) => {
setProgress(p)
})
window.electronAPI.onUpdateReady(() => {
setIsReady(true)
setProgress(null)
})
}, [])
if (!update) return null
return (
<div className="update-notification">
<p>🎉 バージョン {update.version} が利用可能です</p>
{progress !== null ? (
<div className="progress-bar">
<div style={{ width: `${progress}%` }} />
<span>{progress}%</span>
</div>
) : isReady ? (
<button
onClick={() => window.electronAPI.installUpdate()}
>
再起動してインストール
</button>
) : (
<button
onClick={() => window.electronAPI.downloadUpdate()}
>
ダウンロードして更新
</button>
)}
</div>
)
}
試用版・サブスクリプション・買い切りの収益化設計
デスクトップAIアプリの収益化は、SaaSとは異なる考え方が必要です。
収益化モデルの比較
- 試用版(7〜14日)→ 買い切り: シンプルで離脱率が低いです。個人向けツールに適している
- 月額サブスクリプション: 継続収益が安定。Claude APIのコスト(従量制)に対応しやすい
- フリーミアム(機能制限): 上位機能への誘導が可能。GPT系アプリで実績のあるモデル
ライセンス検証の実装(LemonSqueezy の例)
// src/main/licenseManager.ts
import Store from 'electron-store'
interface LicenseData {
licenseKey: string
email: string
expiresAt: string | null // nullは買い切り(無期限)
plan: 'trial' | 'monthly' | 'lifetime'
}
class LicenseManager {
private store: Store<{ license?: LicenseData }>
private readonly LEMON_SQUEEZY_API = 'https://api.lemonsqueezy.com/v1'
private readonly PRODUCT_ID = 'YOUR_PRODUCT_ID'
constructor() {
this.store = new Store({ name: 'license' })
}
async activate(licenseKey: string, email: string): Promise<boolean> {
try {
const response = await fetch(
`${this.LEMON_SQUEEZY_API}/licenses/activate`,
{
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
license_key: licenseKey,
instance_name: `${email}-${process.platform}`
})
}
)
if (!response.ok) return false
const data = await response.json()
this.store.set('license', {
licenseKey,
email,
expiresAt: data.license_key.expires_at,
plan: this.detectPlan(data)
})
return true
} catch {
return false
}
}
async validate(): Promise<boolean> {
const license = this.store.get('license')
if (!license) return false
// 買い切りは期限チェック不要
if (license.plan === 'lifetime') return true
// サブスクリプションは有効期限確認
if (license.expiresAt) {
const expiresAt = new Date(license.expiresAt)
if (expiresAt < new Date()) {
// 期限切れ — サーバーで再確認
return await this.revalidateWithServer(license.licenseKey)
}
}
return true
}
private detectPlan(data: Record<string, unknown>): LicenseData['plan'] {
// LemonSqueezy のレスポンスからプラン種別を判定
const variantId = (data as Record<string, Record<string, string>>).license_key?.variant_id
if (variantId === 'YOUR_LIFETIME_VARIANT_ID') return 'lifetime'
if (variantId === 'YOUR_MONTHLY_VARIANT_ID') return 'monthly'
return 'trial'
}
private async revalidateWithServer(key: string): Promise<boolean> {
try {
const response = await fetch(
`${this.LEMON_SQUEEZY_API}/licenses/validate`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ license_key: key })
}
)
const data = await response.json()
return data.valid === true
} catch {
// オフラインの場合はキャッシュを信頼(UX優先)
return true
}
}
isTrialActive(): boolean {
// 試用期間の判定(インストール日からN日以内)
const installDate = this.store.get('installDate' as never) as string | undefined
if (!installDate) {
this.store.set('installDate' as never, new Date().toISOString() as never)
return true
}
const daysSinceInstall =
(Date.now() - new Date(installDate).getTime()) / (1000 * 60 * 60 * 24)
return daysSinceInstall <= 14
}
getLicense(): LicenseData | undefined {
return this.store.get('license')
}
}
export const licenseManager = new LicenseManager()
試用期限チェックと機能制限
// src/main/appGate.ts
import { licenseManager } from './licenseManager'
export async function checkAccess(): Promise<{
allowed: boolean
reason: 'licensed' | 'trial' | 'expired'
daysLeft?: number
}> {
// ライセンスが有効
const isValid = await licenseManager.validate()
if (isValid) return { allowed: true, reason: 'licensed' }
// 試用期間内
if (licenseManager.isTrialActive()) {
return { allowed: true, reason: 'trial', daysLeft: 14 }
}
return { allowed: false, reason: 'expired' }
}
配布と署名:Mac・Windows への対応
macOS のコード署名と公証(Notarization)
Mac App Store 外で配布する場合でも、macOS 10.15以降は公証(Notarization)が必須です。
// package.json の build 設定
{
"build": {
"mac": {
"hardenedRuntime": true,
"gatekeeperAssess": false,
"entitlements": "build/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.plist",
"notarize": {
"teamId": "YOUR_TEAM_ID"
}
}
}
}
build/entitlements.mac.plist で必要な権限を宣言します:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<!-- keytarに必要 -->
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>
Windows Installer 設定(NSIS)
{
"build": {
"win": {
"target": [
{
"target": "nsis",
"arch": ["x64", "arm64"]
}
],
"signingHashAlgorithms": ["sha256"],
"certificateFile": "path/to/certificate.pfx",
"certificatePassword": "${WINDOWS_CERTIFICATE_PASSWORD}"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"installerIcon": "build/installer.ico",
"createDesktopShortcut": true
}
}
}
GitHub Actions でのリリース自動化
# .github/workflows/release.yml
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
strategy:
matrix:
os: [macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- name: Build & Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
run: npm run release
セキュリティのベストプラクティス
デスクトップAIアプリで特に注意すべきセキュリティ項目をまとめます。
BrowserWindow の必須セキュリティ設定
// src/main/index.ts
const mainWindow = new BrowserWindow({
webPreferences: {
// 最重要:Node.js統合を無効化
nodeIntegration: false,
// コンテキスト分離を有効化
contextIsolation: true,
// preloadスクリプトのみでNode.js APIを使用
preload: path.join(__dirname, '../preload/index.js'),
// 外部ナビゲーションを防止
sandbox: true
}
})
// 外部リンクはデフォルトブラウザで開く
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url)
return { action: 'deny' }
})
// ナビゲーション制限(フィッシング対策)
mainWindow.webContents.on('will-navigate', (event, url) => {
const parsedUrl = new URL(url)
if (parsedUrl.origin !== 'http://localhost:5173') {
event.preventDefault()
}
})
Content Security Policy の設定
// src/main/index.ts(BrowserWindow作成後)
mainWindow.webContents.session.webRequest.onHeadersReceived(
(details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [
"default-src 'self';" +
"script-src 'self';" +
"style-src 'self' 'unsafe-inline';" +
"connect-src 'self' https://api.anthropic.com https://api.lemonsqueezy.com;"
]
}
})
}
)
まとめ
Electron × Claude API のデスクトップAIアプリ開発で押さえるべき核心は以下の通りです。
- セキュリティの基本:
nodeIntegration: false + contextIsolation: true + keytarによるAPIキー管理は妥協しない
- ストリーミングはIPCで: Mainプロセスでストリームを受け取り、
webContents.send() でRendererに転送する設計が安定している
- 自動アップデートは初期から組み込む: electron-updaterの設定は後から追加するより、最初から組み込む方が容易
- 収益化は早期に設計する: LemonSqueezy / Paddleのようなツールは試用版→有料版の導線設計が比較的簡単
- 署名・公証は必須: 特にmacOSの公証はリリース前に必ず通過しておく
Electronアプリの配布後、ユーザーの行動データを効率的に収集して改善サイクルを回したい場合は、Claude API ストリーミング × リアルタイムチャットUI 本番実装ガイドと組み合わせてリアルタイム体験の品質を高めることをお勧めします。