はじめに
「Claude Codeの開発ログをQiita記事に半自動変換するSaaS」としてWeb版(Next.js + FastAPI + PostgreSQL)を作って運用していたが、構造的な問題を抱えていた。
Claude Codeのセッションログは ~/.claude/projects/*.jsonl というローカルファイルなのに、アプリはVPS上で動いている。結果、ログ取り込みのたびに「本文をコピペしてAPIにPOST」という本末転倒な運用が発生していた。
「じゃあデスクトップアプリにしたらいいのでは?」
この一言で、Tauri 2を使ったローカルファーストのデスクトップ版を1日で実装し、GitHubに公開するまでをまとめる。
やったこと
| フェーズ | 内容 |
|---|---|
| 技術選定 | Tauri 2 + Vite + React + TypeScript |
| バックエンド | Rust(reqwest / tauri-plugin-sql / keyring) |
| データ層 | SQLite(WALモード)+ マイグレーション |
| APIキー保管 | OS Keyring → ファイル方式(後述) |
| 外部API | Rust側reqwestでClaude API / Qiita API v2を呼ぶ |
| 配布 |
.app + .dmg(7.9MB) |
ElectronであれBundleサイズは~150MBになるところが、Tauriではわずか7.9MB。OS標準WebViewを使うことによる恩恵をまず数字で実感できた。
Claude Codeログリーダーをそのままにしない
Python版の claude_log_reader.py(約380行)が担っていた処理を、Rustの claude_log.rs に移植した。
// src-tauri/src/claude_log.rs(抜粋)
#[derive(Debug, Serialize, Deserialize)]
pub struct ClaudeProject {
pub dir: String,
pub sessions: usize,
pub last_modified: String,
}
#[tauri::command]
pub async fn list_claude_projects() -> Result<Vec<ClaudeProject>, String> {
let base = dirs::home_dir()
.ok_or("home dir not found")?
.join(".claude/projects");
// mtime降順でプロジェクト一覧を返す
// ...
}
フロント側はシンプルに invoke() で呼ぶだけ:
import { invoke } from "@tauri-apps/api/core";
const projects = await invoke<ClaudeProject[]>("list_claude_projects");
ハマったポイント
1. Tauri 2のcamelCase / snake_case変換は「トップレベルのみ」
Tauri 2はRustのコマンド引数をJSのcamelCaseと自動変換してくれる。ただしこれはコマンドの直接引数(トップレベル)に限る。
// トップレベルはTauriが自動変換してくれる
#[tauri::command]
async fn sync_item(item_id: String, private: bool) -> Result<...> { ... }
しかしstructに包んで渡すと変換が効かない:
// NG: structの中身は自動変換されない
#[derive(Deserialize)]
struct SyncArgs {
item_id: String, // ← JSからitemIdで来ても届かない!
}
修正は #[serde(rename_all = "camelCase")] をstructに追加するだけ:
// OK
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")] // ← これが必要
struct SyncArgs {
item_id: String,
}
この罠にハマって「常にPOST分岐(新規作成)が走る」バグが発生し、Qiitaに同じタイトルの限定共有記事が複数作られた。
2. macOS Keychain + 未署名バイナリで読み取り失敗
keyring crateでAPIキーをOS Keychainに保存する実装をしたが、開発中に頻繁にRust再ビルドが走るとバイナリが変わるたびにKeychainのACLがミスマッチ→読み取り拒否が発生した。
設定でキーを保存 → 別画面へ → 戻ると「未設定」に戻っている
解決策:アプリのデータディレクトリ内のJSONファイル(パーミッション600)に切り替えた。
// src-tauri/src/keyring_store.rs
pub fn set_secret(app: &AppHandle, key: &str, value: &str) -> AppResult<()> {
let path = get_secrets_path(app)?;
// ~/Library/Application Support/<bundle-id>/secrets.json に書き込む
let mut map = load_map(&path)?;
map.insert(key.to_string(), value.to_string());
save_map(&path, &map)
}
署名済みリリースバイナリならKeychainを再び使える。開発中はファイル方式が安定する。
3. Qiita PATCH APIの地雷:tweetフィールドは新規作成専用
Qiita APIの更新(PATCH)で tweet: false を含めると400 Bad Requestが返ってくる。
// NG: PATCHにtweet を含めると 400
let body = json!({
"title": title, "body": content, "tags": tags,
"private": private,
"tweet": false // ← PATCHでは使えない
});
// OK: POST/PATCHで分岐する
let body = if item_id.is_some() {
json!({"title": title, "body": body, "tags": tags, "private": private})
} else {
json!({"title": title, "body": body, "tags": tags, "private": private, "tweet": false})
};
また取り下げ(private: true に切り替え)時にQiita側で同タイトルの限定共有記事が残っていると422(タイトル重複)が発生する。フルペイロードで送ること自体は正しく、孤立した限定共有記事の削除が必要なケース。
4. 自動アップデートボタンが無反応
tauri-plugin-updater を実装してダウンロードボタンを設置したが、クリックしても何も起きない。原因は window.confirm() だった。
// NG: Tauri 2 (Wry) は window.confirm() を silently 無視する
if (!confirm("更新しますか?")) return; // undefinedが返るので常にreturn
await installUpdate();
// OK: confirm() を使わず直接実行 + toastで進行を表示
const install = async () => {
setInstalling(true);
const t = toast.loading("ダウンロード中…");
try {
await installUpdate();
} catch (e) {
toast.error(`更新失敗: ${e}`, { id: t });
setInstalling(false);
}
};
学び
「ローカルにあるものはローカルで読む」 という当たり前の設計が、Webアプリでは実現できなかった。TauriはこのギャップをRust + WebViewで埋める。
(個人の感想)「Rustがわからないとキツいのでは」と思っていたが、Tauri 2はプラグインが整備されていてRustを深く書かなくても動いた。コードベースの9割はReact + TypeScriptで、Rust側は「OSのリソースにアクセスする橋渡し」に専念できる設計だった。
最終的に1日でv0.1〜v0.4.0(自動アップデート・スキャン機能・note対応・並列生成)まで達した。当初の「実装予定5項目」は気がついたら12機能になっていた。これもまたClaude Codeとの開発の典型的な展開かもしれない。
リポジトリ
本記事のベースになったWeb版はこちら:
https://github.com/cotton1101/qiitto
関連記事