0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

FastAPI製カンバンツールをElectronでデスクトップアプリ化した話——PyInstaller同梱という選択

0
Posted at

はじめに

前回の記事(第5弾: 日報・週報・月報自動生成機能)で、AI連携のレポート生成機能を追加した Kizuki。

ふと気になったことがあります。

「これ、Docker も Python も入っていない職場の PC で使えないよな……」

自宅では Dev Container でサクっと動くが、社用 PC には環境構築の権限がないケースは珍しくない。
そこで今回は Electron + PyInstaller を使って、インストーラー一発で動くデスクトップアプリに仕立てました。


背景・動機

なぜ Electron か

Kizuki のフロントエンドはすでに「ブラウザで動く HTML/CSS/JS」です。
Electron はその Web UI をそのままデスクトップウィンドウに表示できます。
既存コードをほぼ変えずに「アプリっぽい体験」を実現できるのが魅力です。

比較した選択肢:

方法 メリット デメリット
Electron + PyInstaller 既存コード流用 / 配布が exe 一個 バイナリが大きい(~100MB)
Tauri + Sidecar バイナリが小さい Rust 学習コスト・設定が複雑
nw.js シンプル メンテが下火

今回はスピードを優先して Electron を選択。

なぜ PyInstaller か

FastAPI(Python)サーバーをそのまま同梱するには、Python ランタイムごとバイナリ化する必要があります。
PyInstaller は Python スクリプトを単体の実行ファイル(.exe)に変換できます。


アーキテクチャ

Electron メインプロセス (electron/main.js)
  └── 子プロセス起動: kizuki-server.exe (PyInstaller バイナリ)
        └── FastAPI + uvicorn (ポート: 58765)
              └── SQLite DB: %APPDATA%/Kizuki/issuelog.db

Electron レンダラー
  └── BrowserWindow → http://localhost:58765/
        └── 既存の static/index.html をそのまま表示

ポイントは「Electron がサーバーを子プロセスとして起動・管理する」点です。


実装

Step 1: Python コードを2行修正

src/database.py(DB パスを環境変数から取得)

import os

_db_path = os.environ.get("KIZUKI_DB_PATH", "./data/issuelog.db")
DATABASE_URL = f"sqlite:///{_db_path}"

src/main.py(PyInstaller の _MEIPASS に対応)

import sys, os

if getattr(sys, "frozen", False):
    _base_dir = sys._MEIPASS          # PyInstaller 展開先
else:
    _base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

_static_dir = os.path.join(_base_dir, "static")

# ...
app.mount("/static", StaticFiles(directory=_static_dir), name="static")

Step 2: CDN 依存をベンダー化

オフライン環境で動かすには、CDN から読み込んでいたライブラリをローカルに落とす必要があります。

curl -sL "https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js" \
     -o static/vendor/Sortable.min.js
curl -sL "https://cdn.jsdelivr.net/npm/marked@12.0.0/marked.min.js" \
     -o static/vendor/marked.min.js

index.html の参照先を変更:

<!-- before -->
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>

<!-- after -->
<script src="/static/vendor/Sortable.min.js"></script>

Step 3: PyInstaller エントリーポイント

# server_entry.py
import multiprocessing, os, sys

def main():
    import uvicorn
    port = int(os.environ.get("KIZUKI_PORT", "58765"))
    db_path = os.environ.get("KIZUKI_DB_PATH", "./data/issuelog.db")

    db_dir = os.path.dirname(db_path)
    if db_dir and not os.path.exists(db_dir):
        os.makedirs(db_dir, exist_ok=True)

    if getattr(sys, "frozen", False):
        sys.path.insert(0, sys._MEIPASS)

    uvicorn.run("src.main:app", host="127.0.0.1", port=port, log_level="warning")

if __name__ == "__main__":
    multiprocessing.freeze_support()  # Windows 必須
    main()

multiprocessing.freeze_support()Windows で PyInstaller バイナリを子プロセスとして起動するときに必須 です(これを忘れると子プロセスが無限増殖します)。

Step 4: Electron メインプロセス

// electron/main.js(抜粋)
async function startAndWait() {
  // DB ディレクトリを事前作成(%APPDATA%/Kizuki/)
  fs.mkdirSync(DB_DIR, { recursive: true });

  // Python サーバーを子プロセスで起動
  serverProcess = spawn(binPath, [], {
    env: { ...process.env, KIZUKI_PORT: "58765", KIZUKI_DB_PATH: DB_PATH },
  });

  // /health をポーリングして起動完了を待機(最大20秒)
  await waitForServer(20000);

  // BrowserWindow で FastAPI の UI を表示
  createWindow();  // loadURL("http://127.0.0.1:58765")
}

アプリ終了時は Python プロセスを確実に終了します:

app.on("will-quit", () => {
  if (process.platform === "win32") {
    execSync(`taskkill /PID ${serverProcess.pid} /F /T`);
  } else {
    serverProcess.kill("SIGTERM");
  }
});

Windows では SIGTERM が効かないため taskkill を使います。


ハマりどころ

1. hiddenimports の地獄

PyInstaller は動的インポートを静的解析できません。uvicorn, sqlalchemy.dialects.sqlite, anyio._backends._asyncio などは kizuki.spechiddenimports に手動で追加が必要です。

エラーが出たらログを見て追加、というサイクルを繰り返します。

hiddenimports=[
    'uvicorn.loops.auto',
    'uvicorn.protocols.http.h11_impl',
    'sqlalchemy.dialects.sqlite',
    'anyio._backends._asyncio',
    # ...
],

2. sys._MEIPASS でのパス解決

PyInstaller でバイナリ化すると、static/ などのファイルは sys._MEIPASS に展開されます。
"static" という相対パスではなく、os.path.join(sys._MEIPASS, "static") で絶対パス指定が必要です。

3. Windows でのプロセス終了

child_process.kill("SIGTERM") は macOS/Linux では機能しますが、Windows では効きません。
taskkill /PID <pid> /F /T で強制終了する分岐が必要です。/T フラグで子プロセスも巻き込んで終了できます。

4. CDN のオフライン問題

社内ネットワークでは CDN にアクセスできないケースがあります。
Sortable.min.jsmarked.min.jsstatic/vendor/ にダウンロードしてベンダー化し、index.html の参照を変更しました。


ソースコード


まとめ

項目 内容
Electron + PyInstaller Python/FastAPI をそのまま .exe 化して同梱
CDN ベンダー化 オフライン環境でも動作する
DB パス %APPDATA%/Kizuki/issuelog.db でユーザーデータを永続化
プロセス管理 Electron が子プロセスを起動・監視・終了

「既存のWeb UIをそのままデスクトップ化したい」ケースでは Electron + PyInstaller の組み合わせは手軽で有効です。
バイナリサイズが大きくなる(~100MB)のは妥協点ですが、配布の手軽さとのトレードオフで許容範囲でした。

次は実際に Windows 上でビルドして配布する予定です。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?