この記事で触れること・わかること
- Rust の Web フレームワーク axum × Tauri でデスクトップアプリを作る方法
-
mpsc::channelを使ったreadyシグナルの活用方法 - グレースフルシャットダウン
はじめに
こんにちわ、itsuki です。
普段は Rust を中心に、Web アプリやデスクトップアプリを個人開発しています。
今回は、Rust/axum 製の マークダウン Wiki を、Tauri 2 で Windows 向けデスクトップアプリとして単一配布できる形に作り直した MarkdownWiki2-SingleBin を紹介します。
このプロジェクトは、もともと PostgreSQL 前提で動かしていた MarkdownWiki2 というプロジェクトを、Windows 環境でオフライン完結かつ簡単に配布できる構成 に再設計したものです。
そのため名前が SingleBin です。
マークダウンベースの Wiki は便利ですが、実際に使おうとすると次のような壁があります。
- DB のセットアップが必要
- Web サーバの起動が必要
- 静的アセットの配布が少し面倒
- 家庭内や小規模チームで使うには少し構えが大きい
そこで今回は、インストーラで入れてそのまま使える マークダウン Wiki を目指しました。
MarkdownWiki2-SingleBin とは
MarkdownWiki2-SingleBin は、Rust/axum 製 API サーバ と Vue 3 製フロントエンド を 1 つの配布物にまとめた Markdown Wiki アプリケーションです。
Tauri 2.0 のデスクトップアプリ として起動し、初回セットアップ後にローカルの axum サーバを起動して UI を表示します。
主な機能
| カテゴリ | 機能 |
|---|---|
| Wiki | 作成、閲覧、更新、削除、公開/非公開切替 |
| 編集支援 | Ace Editor、Vim モード、目次、プレビュー |
| 表現力 | Mermaid、KaTeX、コードハイライト、動画/YouTube 埋め込み |
| ファイル | 画像、PDF、MP4 のアップロードと Markdown 埋め込み |
| 共有 | 期限付き共有 URL の発行 |
| 協業 | 公開 Wiki に対する更新申請フロー |
| 認証 | Cookie + JWT、TOTP 二段階認証 |
| 管理 | 管理者画面、ユーザー作成、ロック解除、公開名変更 |
一言で言うと、ローカルで完結できる多機能 Wikiサーバ を、Web アプリの使い勝手のままデスクトップ配布できるようにした プロジェクトです。
技術スタック
バックエンド: Rust 2024 + axum + SQLx + SQLite
フロントエンド: Vue 3 + Vue Router + Pinia + Vite
デスクトップ: Tauri 2
テンプレート: Tera
認証: JWT + HttpOnly Cookie + TOTP
静的アセットの埋め込み: rust-embed
エディタ: Ace Editor
表示拡張: marked + Prism + KaTeX + Mermaid
配布: GitHub Actions + NSIS Installer
なぜこの構成にしたのか
このプロジェクトの主題は、単に「Wiki を作る」ことではなく
- 既存のWebサーバアプリのコード資産を活かし、そのままデスクトップアプリに仕上げること
- 配布と運用のコストを極力下げること
そのため、次のような方針を取りました。
- DB は SQLite にして、別途 PostgreSQL を要求しない
- フロントエンド成果物は rust-embed でバイナリに埋め込む
- Tauri 2 を使い、Windows インストーラとして配布する
- 必要なら
-sオプションでサーバ単体モードでも動かす
つまり、Webアプリとしての設計を保ちつつ、配布形態だけをデスクトップアプリ寄りにしたイメージです。
アーキテクチャ
全体像はかなりシンプルです。
Tauri WebView
↓ http://localhost:3080/index
axum Server (同一バイナリ内)
├── API
├── 認証/認可
├── 静的アセット配信 (rust-embed)
├── Tera テンプレート配信
└── SQLite
ポイントは、Tauri が直接 UI を持つというより、同一プロセス内で axum サーバを起動し、その localhost を WebView で開いている ことです。
この構成にしておくと、既存の Webアプリ資産をかなり自然に流用できます。
フロントエンドは 3 系統
このプロジェクトではフロントエンドが 1 つではありません。
-
frontend/: PC 向け -
frontend-mobile/: モバイル向け -
frontend-admin/: 管理者向け
これらを個別にビルドし、最終的に dist/ へ集約してからバイナリに埋め込みます。
「1アプリ 1SPA」ではなく、デバイスや役割ごとに UI を分けたうえで単一配布にまとめる 手法です。
実装のポイント
1. Tauri の中で axum を動かす仕組み
このプロジェクトで一番技術的に熟考した点が、Tauri の中で axum サーバを起動し、その localhost を WebView で開く 構成です。
通常の Tauri アプリは、WebviewUrl::App でビルド済みフロントエンドを直接 WebView に渡します。しかし本プロジェクトでは、同一バイナリ内の axum サーバが http://localhost:3080/index を提供し、WebView はその URL を開きます。
tauri::WebviewWindowBuilder::new(
app,
"main",
tauri::WebviewUrl::External(WINDOW_URL.parse().unwrap()),
)
.title(&CONFIG.app_title)
.maximized(true)
.initialization_script(OPEN_EXTERNAL_SCRIPT)
.build()?;
axum の起動タイミング問題と ready チャンネル
WebView を開く前に axum が確実に起動している必要があります。tauri::async_runtime::spawn で axum を非同期起動するだけでは、サーバが listen 状態になる前に WebView がアクセスしてしまう恐れがあります。
これを解消するため、標準ライブラリの mpsc::channel を使った ready シグナル を導入しています。
// axum 起動完了通知チャネル
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<()>();
tauri::async_runtime::spawn(async move {
...
let listener = match tokio::net::TcpListener::bind(SERVER_ADDR).await {
...
},
};
// Tauriウィンドウ作成前にサーバ準備完了を通知
let _ = ready_tx.send(());
axum::serve(listener, app_router)
.with_graceful_shutdown(async move {
shutdown_rx.await.ok();
...
})
.await
.unwrap();
});
// axum が起動するまで最大 30 秒待機してから WebView を開く
ready_rx.recv_timeout(std::time::Duration::from_secs(30))
.expect("axum server failed to start within 30 seconds");
// メインウィンドウを作成
tauri::WebviewWindowBuilder::new(
app,
"main",
tauri::WebviewUrl::External(WINDOW_URL.parse().unwrap()),
) ...
.build()?;
TcpListener::bind が完了した時点(= ポートが listen 状態になった時点)で通知するため、WebView が接続に失敗することがありません。
ポイントとして、axum サーバの起動は高速です。同様のことを「Python + Electron」などで実装すると、ワンテンポ起動が遅れます。
ウィンドウ破棄時のグレースフルシャットダウン
メインウィンドウが閉じられたとき、axum を適切に停止する必要があります。tokio::sync::oneshot::Sender をアプリ状態として保持し、on_window_event からシャットダウン信号を送ります。
// axum シャットダウン信号を保持する Tauri 管理状態
struct ShutdownState(Arc<Mutex<Option<tokio::sync::oneshot::Sender<()>>>>);
.on_window_event(move |window, event| {
// メインウィンドウが破棄されたら axum にシャットダウン信号を送信
if window.label() == "main" {
if let tauri::WindowEvent::Destroyed = event {
if let Ok(mut guard) = shutdown_for_event.lock() {
if let Some(tx) = guard.take() {
let _ = tx.send(()); // axum にシャットダウンを通知
}
}
}
}
})
axum 側では with_graceful_shutdown にこのシグナルを渡しているので、ウィンドウを閉じると axum が接続処理を完了してから終了します。
2. セットアップ画面はカスタムプロトコルで配信する
初回起動時、axum はまだ起動していません。しかし設定入力のための UI を表示する必要があります。
この問題を解決するのが Tauri のカスタム URI スキーム です。app-setup:// というプロトコルを登録し、axum 不要で HTML を直接配信します。
.register_uri_scheme_protocol("app-setup", |_app, _req| {
let html = include_str!("../setup/index.html");
tauri::http::Response::builder()
.header("Content-Type", "text/html; charset=utf-8")
.body(html.as_bytes().to_vec())
.unwrap()
})
セットアップ画面の HTML は include_str! でバイナリに直接埋め込んでいるため、外部ファイルは不要です。
セットアップ完了ボタンを押すと、complete_setup という Tauri コマンドが呼ばれます。
#[tauri::command]
async fn complete_setup(
app: tauri::AppHandle,
shutdown_state: tauri::State<'_, ShutdownState>,
form: SetupForm,
) -> Result<(), String> {
// 設定 JSON 保存 → 環境変数設定 → DB 初期化 → axum 起動 → メインウィンドウ生成
// → セットアップウィンドウを閉じる
if let Some(w) = app.get_webview_window("setup") {
let _ = w.close();
}
Ok(())
}
フォームの送信から DB 初期化・axum 起動・ウィンドウ切り替えまで、すべてこの 1 つのコマンド内で完結 します。
3. 外部リンクをデフォルトブラウザに委譲する
WebView 内で target="_blank" のリンクをクリックしたとき、そのまま WebView 内で開いてしまうと UX が悪いです。ページの復元も辛いです。
Tauri 2 の on_navigation では新規ウィンドウ要求をインターセプトできないため、初期化スクリプトでクリックイベントを捕捉し、localhost 以外のリンクは Tauri コマンド経由でデフォルトブラウザに委譲 しました。
const OPEN_EXTERNAL_SCRIPT: &str = r#"
document.addEventListener('click', function(e) {
var a = e.target.closest('a');
if (!a || !a.href) return;
try {
var host = new URL(a.href).hostname;
if (host !== 'localhost' && host !== '127.0.0.1') {
e.preventDefault();
window.__TAURI_INTERNALS__.invoke('open_url', { url: a.href });
}
} catch (_) {}
}, true);
"#;
Tauri 側は open クレートを使ってブラウザを起動します。
#[tauri::command]
fn open_url(url: String) {
let _ = open::that(url);
}
このスクリプトは WebView の initialization_script として事前に登録しているため、どのページを開いていても自動的に有効です。
4. Windows リリースビルドとコンソール制御
Tauri アプリを Windows でリリースビルドすると、次のアトリビュートによってコンソールウィンドウが非表示になります。
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
一方、-s オプションでサーバ単体モード起動した場合はコンソールがないと不便です。そこで、サーバモード時は Windows API を使って手動でコンソールを確保しています。
fn run_server_mode(bind_addr: String) {
#[cfg(windows)]
utils::ensure_console(); // AllocConsole() を呼び出す
println!("Server mode: binding to http://{}", bind_addr);
// ...
}
デスクトップアプリとサーバモードで挙動を切り替えるこの工夫は、同一バイナリで 2 つの起動形態を支える上で欠かせない部分です。
5. 初回起動時にセットアップ画面を出し、そのまま環境を作る
このアプリは初回起動時、~/.markdown-wiki2-single/ 配下に設定ディレクトリを作ります。
~/.markdown-wiki2-single/
├── markdown-wiki2-single.env.json
├── markdown-wiki2.sqlite
└── images/
セットアップ画面では、アプリタイトルや管理者ユーザー、トークン有効期限、ログイン失敗制御の閾値などを入力します。
その情報をもとに JSON 設定を保存し、SQLite を作成し、初期データ投入まで一気に進めます。
この方式の良いところは、.env を手で書かなくてもよいことです。
GUI で初回セットアップを完了したら、そのままローカル Wiki が使い始められる のは、配布物としてかなり扱いやすいです。
6. 静的アセットは rust-embed でまとめて内包する
Vue 3 でビルドした成果物は最終的に dist/ へ集約され、それを Rust 側で rust-embed により埋め込みます。
#[derive(RustEmbed)]
#[folder = "dist/"]
struct Asset;
#[derive(RustEmbed)]
#[folder = "dist/templates/"]
struct Templates;
これにより、フロントエンド成果物やテンプレートを外部ファイルとして別配布しなくて済む ようになります。
ビルドの流れはおおむね次の通りです。
frontend/ npm run build ─┐
frontend-mobile/ npm run build ─┼─→ main/dist/ に集約 ─→ ルート dist/ にコピー
frontend-admin/ npm run build ─┘
↓
rust-embed で内包
3 系統のフロントエンドを集約する処理は src_frontend/scripts/frontends-builder.ps1 という PowerShell スクリプトが担っています。各フロントエンドをビルドし、index-mobile.html・index-admin.html へのリネームやアセットパスの書き換えも自動化されています。
Tera テンプレートも同様に rust-embed から読み込みます。
fn build_tera_from_embed() -> anyhow::Result<Tera> {
let mut tera = Tera::default();
for path in Templates::iter() {
if let Some(file) = Templates::get(path.as_ref()) {
let content = std::str::from_utf8(file.data.as_ref())?;
tera.add_raw_template(path.as_ref(), content)?;
}
}
Ok(tera)
}
Tauri アプリ化すると「フロントエンドをどう同梱するか」で悩みがちですが、ここをシンプルにバイナリに詰め込むことができるのは大きいです。
7. User-Agent によるモバイル/PC 振り分け
/index へのリクエストに含まれる User-Agent を axum ハンドラで検査し、Mobile が含まれる場合は index-mobile.html を返します。
async fn index_handler(headers: HeaderMap) -> Result<Html<String>, AppError> {
let is_mobile = headers
.get("user-agent")
.and_then(|ua| ua.to_str().ok())
.map_or(false, |ua| ua.contains("Mobile"));
let render_html = if is_mobile { "index-mobile.html" } else { "index.html" };
match Asset::get(render_html) {
Some(content) => Ok(Html(String::from_utf8(content.data.into_owned()).unwrap())),
None => Err(AppError::NotFound),
}
}
PC・モバイル・管理者の 3 系統すべてが同一バイナリから提供される構造です。
作成画面
プレビュー画面
8. サーバ単体モードも維持している
デスクトップアプリ化しても、CLI から次のように起動できます。
markdown_wiki2_single -s 0.0.0.0
markdown_wiki2_single -s 0.0.0.0:9090
このモードでは Tauri を使わず、axum サーバだけを立ち上げます。
個人用途ではデスクトップアプリとして使い、家庭内や LAN 内ではサーバとして使う、といった運用もできます。
GUI とサーバモードを両立している のは、このプロジェクトの実用面でかなり強いポイントです。
9. 認証まわりは Cookie + JWT + TOTP
ローカル利用前提のアプリケーションであっても、複数人での使用を想定するサーバモードもあるので、認証機構は実装しています。
このアプリでは次の構成を採用しています。
- アクセストークンとリフレッシュトークンを JWT で発行
- HttpOnly Cookie で保持
SameSite=Strict- リフレッシュトークンは専用パス
/account/refreshに限定 - TOTP による二段階認証に対応
また、ログイン失敗回数に応じた待機制御やアカウントロックも入れています。
axum ミドルウェアは 3 種類使い分けています。
| ミドルウェア | 用途 |
|---|---|
CookieValidator |
アクセストークン必須 API |
RefreshCookieValidator |
リフレッシュトークン必須 API |
FlexibleCookieValidator |
匿名でも通す静的画像配信 |
単なる「ローカルアプリ」ではなく、小規模な業務利用や共用環境もある程度見据えた設計 にしている点が、このプロジェクトの特徴の一つとして位置づけ、開発してきました。
10. Markdown 表示の充実化
Markdown Wiki を名乗る以上、表現力も大切です。
このプロジェクトでは以下をサポートしています。
- コードハイライト
- Mermaid
- KaTeX
- 画像、PDF、MP4 の埋め込み
- YouTube 埋め込み
- タスクリスト
- 目次表示
もともと PostgreSQL版からの派生であるため、画像管理、一時共有 URL、更新申請フロー まで含めて運用設計しており、単なるノートアプリより拡張性が大きいことが特徴です。
リリース設計
配布は GitHub Actions で自動化しています。
Windows runner 上で次を実施します。
- Node.js セットアップ
- 3 系統のフロントエンド依存を
npm ci - CI 用 SQLite を生成して
sqlx migrate run -
frontends-builder.ps1で成果物をdist/に集約 -
cargo tauri buildで NSIS インストーラを生成 -
.exeと.msiを収集して GitHub Release に添付(SHA-256 チェックサム付き)
ここでも大事なのは、ビルドのためだけに CI 上で SQLite を組み立て、リリース物そのものには実運用 DB を含めない ことです。
配布物をクリーンに保ちつつ、sqlx のコンパイル時クエリ検証も通せる構成になっています。
このプロジェクトから得た技術的資産
Web アプリの構造を壊さずに、デスクトップ配布へ寄せていく設計 を Rust で実装できた ことが、私にとっての技術的資産となりました。
Electron 的に「フロントエンドとネイティブ API を IPC で密結合する」のではなく、
- UI は Web
- API は axum
- 永続化は SQLite
- 配布形態は Tauri
という分離を維持したまままとめ上げています。
その結果として、
- 開発中は Web アプリとして考えやすい
- 配布時は Windows インストーラとして配れる
- 必要ならサーバ単体でも使える
という、かなりバランスのよい形にできました。
また、Tauri の仕組みを使った部分として、
- カスタム URI スキームでサーバ起動前の UI を提供する
- ready チャンネルで axum の起動を待ってから WebView を開く
-
on_window_event+ oneshot チャンネルでグレースフルシャットダウンを実現する - 初期化スクリプトで外部リンクをブラウザに逃がす
これらは「WebView を単なる描画機能として使う」のではなく、デスクトップアプリとして自然に振る舞うための細かい調整 の積み重ねです。
おわりに
MarkdownWiki2-SingleBin は、既存の Web アプリ資産を活かしながら、ローカル完結で扱いやすい Wiki として再構成したプロジェクト です。
Tauri 2、axum、SQLite、Vue 3 を組み合わせることで、ブラウザアプリの開発体験とデスクトップ配布のしやすさを両立できました。
特に、
- PostgreSQL を前提にしたくない
- Docker なしで配布したい
- でも Web 的な UI/UX は捨てたくない
というケースでは、かなり有効な構成だと思っています。
同じように「既存の Webサーバを、配りやすい単体アプリにしたい」と考えている方には、ひとつの実例として参考にしていただけると思います。
また、近いうちに、このアプリケーションを最小構成にし、ハンズオン形式で「axum + Tauri で構築するデスクトップアプリ」を記事にしようかと思います。
私のポートフォリオです。👉 use Maruware;



