1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Web版SaaSをTauri 2で作り直したら7.9MBになった話

1
Last updated at Posted at 2026-06-02

はじめに

「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 + .dmg7.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

関連記事

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?