Antigravity × Tauri 2 デスクトップアプリ本番デプロイ完全戦略
Tauri 2 でアプリを完成させたあと、ほとんどの開発者が次に詰まるのは「コードを書く部分」ではありません。tauri build が通ったあとから本当の戦いが始まります。macOS の Notarization、Windows の SmartScreen 警告、自動アップデート、ライセンス認証、課金フロー — これらは公式チュートリアルがあえて深く触れない領域でありながら、有料アプリとして配布するなら避けて通れない関門でもあります。
私自身、個人で iPhone/Android アプリを長年運用してきましたが、デスクトップアプリは別の生き物です。ストアによる審査と配信に頼れない代わりに、自分でインフラを設計しなければなりません。ここではAntigravity のエージェントを活用しながら、本番運用に耐える Tauri 2 アプリのデプロイパイプラインをまるごと組み立てる方法を、実運用で得た落とし穴も含めて共有していきます。
なぜ Tauri 2 の「本番デプロイ」が独特の難所になるのか
Tauri が Electron に対して優位なのは、バイナリサイズと起動速度だけではありません。配布の自由度こそが本質です。Tauri 2 はネイティブインストーラを直接生成し、アップデート基盤も自由に選べる設計になっています。この自由度は強力ですが、同時に「すべて自分で設計しなければならない」責任を意味します。
Electron 系のフレームワークでは、electron-updater や electron-builder の組み合わせで多くのことが自動化されます。Tauri 2 でも tauri-plugin-updater が用意されていますが、署名鍵の管理、配信エンドポイントの設計、段階的ロールアウトの仕組みは自前で組み上げる必要があります。Antigravity を使う最大の利点は、この「フレームワーク横断の設計判断」を AI エージェントと対話しながら詰められることです。具体的には、Antigravity の Manager Surface で「Rust バックエンドの署名検証」と「TypeScript フロントエンドのライセンス UI」を別エージェントに同時並行で書かせるパターンが本記事を貫く設計思想になります。
なお Tauri 1.x からの移行を検討している方は、Antigravity × Tauri デスクトップアプリ開発ガイド を先に読んでおくと、本記事の文脈が掴みやすくなります。
macOS の Notarization を Antigravity で自動化する完全フロー
macOS で配布する .dmg や .app は、単にコード署名するだけでは「開発元が未確認のため開けません」と表示されます。Apple のサーバーで Notarization(公証)を通し、その結果をバイナリに staple(貼付)するまでが必須の手順です。
私が運用しているフローでは、以下を 1 つのスクリプトに統合しています。Antigravity に「tauri build の後段で実行する notarize.sh を書いて。失敗時は GitHub Actions のジョブをエラー終了させ、エラーログを Slack 通知する」と指示すると、ほぼこの形のスクリプトが返ってきます。
#!/usr/bin/env bash
# notarize.sh - Tauri 2 macOS 公証パイプライン
# 前提: APPLE_ID / APPLE_TEAM_ID / APPLE_APP_PASSWORD / SIGNING_IDENTITY が環境変数に設定済み
set -euo pipefail
APP_PATH = "src-tauri/target/release/bundle/macos/MyApp.app"
DMG_PATH = "src-tauri/target/release/bundle/dmg/MyApp_1.0.0_universal.dmg"
# 1. .app に署名 (--options runtime が必須。これがないと公証で reject される)
codesign --force --deep --options runtime \
--sign " $SIGNING_IDENTITY " \
--entitlements src-tauri/entitlements.plist \
" $APP_PATH "
# 2. ZIP に固める(公証は ZIP/DMG/PKG のみ受付)
ditto -c -k --keepParent " $APP_PATH " "MyApp.zip"
# 3. Apple サーバーへ公証リクエスト(同期待機)
xcrun notarytool submit "MyApp.zip" \
--apple-id " $APPLE_ID " \
--team-id " $APPLE_TEAM_ID " \
--password " $APPLE_APP_PASSWORD " \
--wait \
--output-format json > notarize_result.json || {
SUBMISSION_ID = $( jq -r '.id' notarize_result.json 2> /dev/null || echo "unknown" )
echo "❌ 公証失敗: $SUBMISSION_ID "
# 失敗ログを取得して終了
xcrun notarytool log " $SUBMISSION_ID " \
--apple-id " $APPLE_ID " \
--team-id " $APPLE_TEAM_ID " \
--password " $APPLE_APP_PASSWORD " || true
exit 1
}
# 4. .app と DMG の両方に staple
xcrun stapler staple " $APP_PATH "
xcrun stapler staple " $DMG_PATH "
# 5. 検証(gatekeeper が受け入れるか最終確認)
spctl --assess --type execute --verbose=4 " $APP_PATH "
echo "✅ 公証完了: $DMG_PATH "
このスクリプトの肝は --options runtime と staple の二段階です。前者を忘れると Apple が「Hardened Runtime が有効でない」と reject し、後者を忘れるとオフライン環境のユーザーが「公証情報を確認できません」と弾かれます。両方とも一見わかりにくいエラーで返ってくるので、Antigravity の Sub-Agent に「notarize の結果 JSON を解析してエラーカテゴリを分類するスクリプト」を書かせておくと、運用がぐっと楽になります。
期待される出力は accepted のステータスと、staple 後に spctl が source=Notarized Developer ID を返すことです。これが Unnotarized Developer ID のままなら staple が失敗しています。
Windows コード署名 — EV 証明書と SmartScreen の壁
Windows では SmartScreen が Microsoft Defender と連動しており、署名のないインストーラは「PC への損害を防ぐためにブロックされました」と派手に警告します。EV(Extended Validation)証明書を使えば即座に評価が確立しますが、価格は年 4〜10 万円と決して安くありません。私の運用では、まず通常の OV 証明書で配布を始めて評価を貯め、必要に応じて EV に切り替える段階的アプローチを採っています。
EV 証明書は USB トークンか HSM に保管されるため、CI 環境からの自動署名にはひと工夫が必要です。Azure Key Vault の Code Signing 機能を使うと、CI から署名 API を叩く形で運用できます。具体的な PowerShell スクリプトは次の通りです。
# sign.ps1 - Windows EV 署名 (Azure Key Vault 経由)
$ErrorActionPreference = "Stop"
$ExePath = "src-tauri\target\release\bundle\nsis\MyApp_1.0.0_x64-setup.exe"
$VaultUri = $ env: AZURE_KEY_VAULT_URI
$CertName = $ env: AZURE_KEY_VAULT_CERT_NAME
# AzureSignTool がインストールされていない場合は自動インストール
if ( -not ( Get-Command AzureSignTool - ErrorAction SilentlyContinue)) {
dotnet tool install -- global AzureSignTool
}
try {
AzureSignTool sign `
- kvu $VaultUri `
- kvi $ env: AZURE_CLIENT_ID `
- kvs $ env: AZURE_CLIENT_SECRET `
- kvt $ env: AZURE_TENANT_ID `
- kvc $CertName `
- tr "http://timestamp.digicert.com" `
- td sha256 `
- fd sha256 `
$ExePath
# 署名検証
$sig = Get-AuthenticodeSignature $ExePath
if ($sig.Status -ne "Valid" ) {
throw "署名が無効です: $ ( $sig.Status ) — $ ( $sig.StatusMessage ) "
}
Write-Host "✅ 署名成功: $ ( $sig.SignerCertificate.Subject ) "
}
catch {
Write-Error "❌ 署名失敗: $_ "
exit 1
}
Antigravity でこのスクリプトを書く際、私は必ず「タイムスタンプサーバの URL は変数化してフォールバック先を 2 つ持たせる」と指示します。DigiCert のタイムスタンプサーバが落ちている時間帯(実体験で月に 1〜2 回ある)に署名がエラーになるのを防ぐためです。フォールバック先には http://timestamp.sectigo.com や http://timestamp.globalsign.com/tsa/r6advanced1 などを並べると、年間を通じてビルド失敗をほぼゼロにできます。
自前の自動アップデート基盤を Cloudflare Workers + R2 で構築する
Tauri 2 の tauri-plugin-updater は、JSON マニフェストを読み込んでバージョン比較とダウンロードを行います。マニフェストの配信先には GitHub Releases を使う選択肢もありますが、私は Cloudflare Workers + R2 を推します。理由は次の三点です。
第一に、段階的ロールアウト(10% → 50% → 100%)が Worker のロジックで簡単に書けること。第二に、地域ごとの配信制限や A/B テストを後から追加しやすいこと。第三に、R2 はエグレス無料なので、バイナリ配信のコストが極めて低いことです。
以下が Worker の最小実装です。Antigravity で書いてもらうときは「ロールアウト率は KV に保存し、SHA256 ハッシュは R2 のメタデータから読む」と明示的に伝えると、変更に強い設計になります。
// src/index.ts - Tauri 2 自動アップデートマニフェスト配信
interface Env {
UPDATES_KV : KVNamespace ;
UPDATES_R2 : R2Bucket ;
UPDATE_SIGNATURE_PUBLIC_KEY : string ;
}
interface UpdateMeta {
version : string ;
notes : string ;
pub_date : string ;
rollout : number ; // 0-100
platforms : Record < string , { url : string ; signature : string }>;
}
export default {
async fetch ( req : Request , env : Env ) : Promise < Response > {
const url = new URL (req.url);
// /api/update/{platform}/{currentVersion} に対する応答
const match = url.pathname. match ( / ^ \/ api \/ update \/ ( [ ^ /] + ) \/ ( [ ^ /] + ) $ / );
if ( ! match) return new Response ( "Not Found" , { status: 404 });
const [, platform , currentVersion ] = match;
const userBucket = hashToBucket (req.headers. get ( "x-client-id" ) ?? crypto. randomUUID ());
try {
const metaJson = await env. UPDATES_KV . get ( "latest_meta" , "json" ) as UpdateMeta | null ;
if ( ! metaJson) return new Response ( "No update available" , { status: 204 });
// 段階的ロールアウト判定
if (userBucket > metaJson.rollout) {
return new Response ( "Not eligible for rollout" , { status: 204 });
}
// 同バージョン以下のみ更新を返す
if ( compareSemver (currentVersion, metaJson.version) >= 0 ) {
return new Response ( "Up to date" , { status: 204 });
}
const platformData = metaJson.platforms[platform];
if ( ! platformData) {
return new Response ( `Unsupported platform: ${ platform }` , { status: 400 });
}
// Tauri Updater が期待する形式で返す
return Response. json ({
version: metaJson.version,
notes: metaJson.notes,
pub_date: metaJson.pub_date,
platforms: { [platform]: platformData },
});
} catch (err) {
console. error ( "Update endpoint error:" , err);
// エラー時は 204 を返してアプリ側のクラッシュを防ぐ
return new Response ( "Internal error (silenced)" , { status: 204 });
}
} ,
} ;
function hashToBucket ( id : string ) : number {
let hash = 0 ;
for ( let i = 0 ; i < id. length ; i ++ ) {
hash = ((hash << 5 ) - hash + id. charCodeAt (i)) | 0 ;
}
return Math. abs (hash) % 100 ;
}
function compareSemver ( a : string , b : string ) : number {
const parse = ( v : string ) => v. replace ( / ^ v/ , "" ). split ( "." ). map (Number);
const [ a1 , a2 , a3 ] = parse (a);
const [ b1 , b2 , b3 ] = parse (b);
return a1 - b1 || a2 - b2 || a3 - b3;
}
ここで一つ重要な設計判断があります。Worker がエラーになった場合、私は意図的に 204(No Content)を返しています。Tauri Updater は 200 以外をエラー扱いしますが、この場合「アップデートが利用可能でない」として穏やかに扱うほうが UX が良いからです。500 を返すと、ユーザー側で「アップデート確認に失敗しました」というダイアログが出てしまい、結果として再起動するまで何度も同じエラーを見せることになります。
期待される動作としては、新しいバージョンがある場合に Tauri Updater が JSON を受け取り、URL のバイナリを SHA256 検証つきでダウンロードしてインストールに進みます。ロールアウト率を 10 → 50 → 100 と段階的に上げるオペレーションは、KV の値を更新するだけで完了します。
ロールバック手順も忘れずに設計してください。私は「直前バージョンのマニフェスト JSON を previous_meta キーに常時保管しておき、緊急時は KV のキーを書き換えるだけで切り戻せる」運用にしています。R2 のバイナリは削除せず、URL も変えないことが鉄則です。
ライセンスキー検証システム — オフライン対応の実装パターン
有料デスクトップアプリで悩ましいのが「ネット接続がない環境でもアプリは動かしたいが、海賊版対策もしたい」という相反する要件です。私が採用しているのは、サーバ署名 + クライアント検証のハイブリッド方式です。
仕組みはこうです。Stripe で決済が完了したら、サーバ側で「ユーザー ID + 有効期限 + デバイス指紋ハッシュ」を秘密鍵で署名したライセンスキーを発行します。アプリ側は対応する公開鍵をバイナリに埋め込んでおき、起動時に署名検証だけを行います。これによりオフラインでも検証が可能で、サーバが落ちていてもアプリは起動します。
// src-tauri/src/license.rs - Ed25519 オフラインライセンス検証
use base64 :: { engine :: general_purpose :: STANDARD as B64 , Engine as _};
use ed25519_dalek :: { Signature , Verifier , VerifyingKey };
use serde :: { Deserialize , Serialize };
use std :: time :: { SystemTime , UNIX_EPOCH };
const LICENSE_PUBLIC_KEY : & [ u8 ] = include_bytes! ( "../keys/license_pub.bin" );
#[derive( Debug , Serialize , Deserialize )]
pub struct LicensePayload {
pub user_id : String ,
pub email : String ,
pub expires_at : u64 , // Unix epoch
pub device_fingerprint : String ,
pub plan : String , // "lifetime" | "annual"
}
#[derive( Debug )]
pub enum LicenseError {
Malformed ,
InvalidSignature ,
Expired ,
DeviceMismatch ,
}
pub fn verify_license (
license_str : & str ,
current_fingerprint : & str ,
) -> Result < LicensePayload , LicenseError > {
// ライセンスキーは `<base64(payload)>.<base64(signature)>` の形式
let (payload_b64, sig_b64) = license_str
. split_once ( '.' )
. ok_or ( LicenseError :: Malformed ) ? ;
let payload_bytes = B64 . decode (payload_b64) . map_err ( | _ | LicenseError :: Malformed ) ? ;
let sig_bytes = B64 . decode (sig_b64) . map_err ( | _ | LicenseError :: Malformed ) ? ;
let payload : LicensePayload =
serde_json :: from_slice ( & payload_bytes) . map_err ( | _ | LicenseError :: Malformed ) ? ;
// 署名検証
let key_array : [ u8 ; 32 ] = LICENSE_PUBLIC_KEY
. try_into ()
. map_err ( | _ | LicenseError :: InvalidSignature ) ? ;
let verifying_key =
VerifyingKey :: from_bytes ( & key_array) . map_err ( | _ | LicenseError :: InvalidSignature ) ? ;
let signature = Signature :: from_slice ( & sig_bytes)
. map_err ( | _ | LicenseError :: InvalidSignature ) ? ;
verifying_key
. verify ( & payload_bytes, & signature)
. map_err ( | _ | LicenseError :: InvalidSignature ) ? ;
// 有効期限チェック(lifetime プランは expires_at = 0 で扱う)
if payload . plan != "lifetime" {
let now = SystemTime :: now ()
. duration_since ( UNIX_EPOCH )
. map ( | d | d . as_secs ())
. unwrap_or ( 0 );
if now > payload . expires_at {
return Err ( LicenseError :: Expired );
}
}
// デバイス指紋チェック(同一マシンでのみ有効)
if payload . device_fingerprint != current_fingerprint {
return Err ( LicenseError :: DeviceMismatch );
}
Ok (payload)
}
#[tauri :: command]
pub fn check_license (license : String , fingerprint : String ) -> Result < LicensePayload , String > {
verify_license ( & license, & fingerprint) . map_err ( | e | format! ( "{:?}" , e))
}
この実装で重要なのは、デバイス指紋の取得方法です。MAC アドレスは仮想 NIC で簡単に偽装できますし、ハードディスクのシリアル番号は dual-boot 環境で破綻します。私は OS のインストール時に生成される UUID(macOS なら IOPlatformUUID、Windows なら MachineGuid)を組み合わせ、SHA256 でハッシュ化して使っています。これでも完全な対策ではありませんが、家族間でのカジュアルなコピーは防げますし、本気の海賊版とは別問題と割り切る判断も時には必要です。
なぜサーバ側でライセンス無効化リストを持たないのかという議論もありますが、私はあえて持っていません。返金処理の自動化が複雑になり、ライセンス検証のためのオンライン依存が生まれ、結果としてユーザー体験を損なうからです。重大な悪用を見つけた場合だけ、次のアップデートで該当ライセンスのブラックリストをバイナリに含めるという運用にしています。
Stripe Checkout からライセンス発行までの完全フロー
ライセンス発行を Stripe Webhook と連動させる部分が、もう一つの設計の山場です。Cloudflare Workers で Stripe Webhook を受け、ライセンスを生成して、Resend や Postmark でメール配信するフローを Antigravity の AI エージェントに分担させると、1 時間ほどで実装できます。
ポイントは、Webhook の冪等性(同じイベントを 2 回処理してもライセンスを 2 重発行しない)です。Stripe の event.id を KV に書き込み、既存ならスキップするロジックを必ず入れてください。また、ライセンスメールは「決済直後」だけでなく「ユーザーが再ダウンロードしたい時」にも再送できる仕組みにすると、サポート負荷が劇的に下がります。
決済画面のデザインや Stripe Checkout のロケール対応については、Antigravity × アプリ収益化 Stripe 完全ガイド で詳しく扱っています。本記事の Tauri 用ライセンス発行ロジックと組み合わせれば、商用配布の枠組みは一通り完成します。
よくある間違いと落とし穴
実運用で実際に詰まったポイントを共有していきます。これらは公式ドキュメントに書かれていない、もしくは目立たない場所にしか書かれていない落とし穴です。
第一の落とし穴は、Tauri Updater の公開鍵をビルドごとに変えてしまうケースです。tauri signer generate を CI で毎回実行してしまうと、既存ユーザーのアプリは新しい鍵で署名された更新を「不正な署名」として拒否します。鍵は一度生成したら厳重にバックアップし、次世代鍵への切り替えは「両方の鍵で二重署名する移行期間」を設けてから行ってください。
第二の落とし穴は、macOS の Universal Binary(Intel + Apple Silicon)を作る際の RPATH の問題です。Rust のリンカが arm64 と x86_64 で異なるパスを埋め込んでしまうと、lipo でマージしたバイナリが片方のアーキテクチャでクラッシュします。cargo-zigbuild か Tauri 公式の --target universal-apple-darwin を使い、両アーキテクチャを同じツールチェーンでビルドするのが安全です。
第三の落とし穴は、Windows の SmartScreen が ZIP 圧縮を通すと評価をリセットする現象です。私はこれを発見するまで 3 週間悩みました。NSIS インストーラを ZIP で圧縮して配布すると、Windows は「Mark of the Web」で「ダウンロード元不明」とマークし、署名済みでも警告を出します。配布形式は ZIP ではなく、署名済みの .exe か .msi のまま配信するのが正解です。
第四の落とし穴は、ライセンスキーを設定ファイルに平文で保存する設計です。OS のキーチェーン(macOS の Keychain、Windows の Credential Manager、Linux の Secret Service)に保存するべきです。Tauri 2 には tauri-plugin-stronghold があり、これを使えば暗号化された安全なストレージとして扱えます。
第五の落とし穴は、自動アップデートのテストを「実際に古いバージョンを配って試す」しか方法がないと思い込むことです。Antigravity に「ローカルで Worker を起動して、ダミーマニフェストを返すモックエンドポイントを作って」と頼むと、開発機で再現できる環境がすぐに整います。本番に出す前に、最低でも「同バージョン更新なし」「マイナーアップデート」「メジャーアップデート」「公開鍵不一致」「ネットワーク切断」の 5 ケースをテストしてください。
実プロダクトへの応用シナリオ
このアーキテクチャは、私が個人で運用している複数のアプリで実証済みのものです。具体的には、AI 補助の文章執筆ツール、簡易 SQL クライアント、ローカル動作の画像生成 GUI といった用途で動かしています。共通するのは「ネット接続が不安定でも快適に動く」「買い切り or 年額の料金プランがある」「Mac と Windows の両方で配布する」の三条件です。
特に強調したいのは、Antigravity の AGENTS.md にこの本番デプロイパイプラインの設計判断を文書化しておく価値です。半年後にメンテナンスする時、「なぜ自前のアップデート基盤を使ったのか」「なぜサーバ無効化リストを持たないのか」を読み返せると、判断のブレなく改修ができます。私のプロジェクトの AGENTS.md には、本記事のような落とし穴リストがそのまま入っており、新機能追加で同じ罠を踏まないようにしています。
サーバ運用やプロジェクト規模を一段上に引き上げたい場面では、Antigravity × Cloudflare R2 ファイルアップロード最適化ガイド も合わせて読んでみてください。バイナリ配信の最適化と、ユーザー数 10,000 を超えた時のスケール戦略を補強できます。
全体を振り返って
ここまでの内容を一行で言えば、「Tauri 2 アプリの本番デプロイは、コードを書く時間より仕組みを設計する時間の方が長い」ということに尽きます。署名・公証・更新・ライセンス・課金 — それぞれが独立した小さなプロジェクトになり得ますが、Antigravity のエージェントを上手に使えば、一週間でひとりでも作り切れるレベルに到達できます。
今日から動き始めるなら、まず手元の Tauri プロジェクトに「macOS 公証スクリプト」だけを組み込んでみてください。この一歩を踏み出した瞬間、あなたのアプリは「個人開発の習作」から「販売可能なプロダクト」に変わります。書き上げたスクリプトは GitHub Actions に組み込み、リリースタグを打つだけで配布物が公証済みで生成される状態を目指しましょう。デスクトップアプリで収益化を考えている方には、Antigravity × アプリ収益化 Stripe 完全ガイド と本記事を行き来しながら、自分のプロダクトの設計図を描き直すことをおすすめします。
書籍で体系的に