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」を別エージェントに同時並行で書かせるパターンが本記事を貫く設計思想になります。
もう一つ、Web との決定的な違いがあります。Web の不具合はロールバックすれば数秒で消えますが、デスクトップアプリの不具合は利用者のマシンに残り続けます。次にアプリを起動してもらうまで、こちらから触れる手段がありません。
この非対称性が、更新サーバも、ライセンス形式も、インストーラも「保守的に倒す」理由になります。私はこの前提を理解するまで、配信の設計を Web の感覚で組んでいて、一度作った修正版が届かない期間の長さに肝を冷やしました。
Electron 側の設計と比べたい方は、Antigravity × Electron デスクトップAIアプリケーション開発 に更新とパッケージングの違いをまとめてあります。本記事は「ストアに頼らない配布を全部自前で持つ」側の話です。
macOS の Notarization を Antigravity で自動化する
macOS で配布する .dmg や .app は、単にコード署名するだけでは「開発元が未確認のため開けません」と表示されます。Apple のサーバーで Notarization(公証)を通し、その結果をバイナリに staple(貼付)するまでが必須の手順です。
私が最初に組んだスクリプトは、公証が通ったのに配布したアプリが警告を出す、という不可解な状態を作りました。原因は三つあり、どれも「書き方」ではなく「順番」の誤りでした。
一つ目は codesign --deep に頼っていたこと。Apple は配布用途での --deep を推奨しておらず、sidecar やフレームワークには意図した entitlements が付かないまま素通りします。二つ目は --timestamp の欠落で、これは公証側が secure timestamp なしの署名を拒否します。三つ目が一番厄介で、tauri build が出力した DMG には署名前の .app が入っているのに、その DMG を公証して staple していたことでした。
つまり中身は未署名のまま、外側だけが公証済みという状態です。以下は順番を組み直した版です。
#!/usr/bin/env bash
# notarize.sh - Tauri 2 macOS 公証パイプライン(署名 → DMG 再作成 → 公証 → staple)
# 前提: APPLE_ID / APPLE_TEAM_ID / APPLE_APP_PASSWORD / SIGNING_IDENTITY が環境変数に設定済み
# 前提: tauri build は --bundles app で実行し、DMG は本スクリプトが作る
set -euo pipefail
APP_NAME = "MyApp"
APP_PATH = "src-tauri/target/universal-apple-darwin/release/bundle/macos/${ APP_NAME }.app"
DMG_PATH = "dist/${ APP_NAME }_universal.dmg"
mkdir -p dist
# 1. 内側から署名する。--deep は配布用途では使わない
# (nested code に entitlements が引き継がれず、後から原因が追いにくい失敗になる)
while IFS = read -r -d '' NESTED ; do
codesign --force --options runtime --timestamp \
--sign " $SIGNING_IDENTITY " " $NESTED "
done < <( find " $APP_PATH /Contents/MacOS" " $APP_PATH /Contents/Frameworks" \
-type f -perm -u+x -print0 2> /dev/null)
# 2. 最後に .app 本体を署名する(--timestamp がないと公証が secure timestamp 欠落で落ちる)
codesign --force --options runtime --timestamp \
--sign " $SIGNING_IDENTITY " \
--entitlements src-tauri/entitlements.plist \
" $APP_PATH "
codesign --verify --strict --verbose=2 " $APP_PATH "
# 3. 署名済みの .app から DMG を作り直す(ここを飛ばすと未署名の .app が同梱される)
STAGE = "$( mktemp -d )"
cp -R " $APP_PATH " " $STAGE /"
ln -s /Applications " $STAGE /Applications"
rm -f " $DMG_PATH "
hdiutil create -volname " $APP_NAME " -srcfolder " $STAGE " -ov -format UDZO " $DMG_PATH "
codesign --force --timestamp --sign " $SIGNING_IDENTITY " " $DMG_PATH "
# 4. DMG を公証する。exit code だけに頼らず status を必ず読む
xcrun notarytool submit " $DMG_PATH " \
--apple-id " $APPLE_ID " \
--team-id " $APPLE_TEAM_ID " \
--password " $APPLE_APP_PASSWORD " \
--wait \
--output-format json > notarize_result.json
STATUS = $( jq -r '.status' notarize_result.json )
SUBMISSION_ID = $( jq -r '.id' notarize_result.json )
if [ " $STATUS " != "Accepted" ]; then
echo "公証が通りませんでした: status=${ STATUS } id=${ SUBMISSION_ID }"
xcrun notarytool log " $SUBMISSION_ID " \
--apple-id " $APPLE_ID " \
--team-id " $APPLE_TEAM_ID " \
--password " $APPLE_APP_PASSWORD "
exit 1
fi
# 5. staple と最終検証(DMG は primary-signature を見る)
xcrun stapler staple " $DMG_PATH "
spctl --assess --type open --context context:primary-signature --verbose=4 " $DMG_PATH "
echo "公証完了: $DMG_PATH "
順番を直したあと、旧版と新版の差はコマンドの数ではなく、検証で落ちる位置に出ました。旧版は spctl が .app を見ていたため、DMG 内の未署名バイナリに気づけません。新版は DMG そのものを --context context:primary-signature で評価するので、利用者がダウンロードした状態と同じものを検査できます。
notarytool submit --wait の終了コードだけで合否を判定していたのも危うい設計でした。ステータスが Invalid でも CI が緑になった回があり、そのビルドをそのまま配ってしまったことがあります。JSON の status を明示的に読む数行を足すだけで、この種の取りこぼしは消えます。
期待される出力は notarytool の status が Accepted になること、staple 後の spctl が source=Notarized Developer ID を返すことの二点です。Unnotarized Developer ID のままなら staple が失敗しているか、公証にかけた成果物と配布する成果物が別物になっています。
失敗ログの読み解きは Antigravity の Sub-Agent に任せています。「notarytool log の JSON を受け取り、issues[].message を『entitlements 不足』『timestamp 欠落』『未署名の nested binary』に分類して、該当パスだけを出力して」と頼むと、原因のファイルまで一息で降りられます。公証のエラーは文面が抽象的なので、分類してくれる層が一枚あるだけで復旧時間が変わります。
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;
// 端末ごとに固定された ID を必須にする。ここで randomUUID にフォールバックすると
// 起動のたびにバケットが変わり、段階配信が「毎回くじを引き直す」挙動になる
const clientId = req.headers. get ( "x-client-id" );
if ( ! clientId) return new Response ( "Missing client id" , { status: 400 });
const userBucket = hashToBucket (clientId);
try {
const metaJson = await env. UPDATES_KV . get ( "latest_meta" , "json" ) as UpdateMeta | null ;
if ( ! metaJson) return new Response ( "No update available" , { status: 204 });
// 段階的ロールアウト判定(バケットは 0..99。> だと rollout=0 でも 0 番が通ってしまう)
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 });
}
} ,
} ;
// FNV-1a。分布が素直で、同じ ID なら常に同じバケットに落ちる
function hashToBucket ( id : string ) : number {
let h = 2166136261 ;
for ( let i = 0 ; i < id. length ; i ++ ) {
h ^= id. charCodeAt (i);
h = Math. imul (h, 16777619 );
}
return (h >>> 0 ) % 100 ;
}
function compareSemver ( a : string , b : string ) : number {
// "1.2" や "1.2.0-beta.1" を渡されても NaN を伝播させない
const parse = ( v : string ) =>
v. replace ( / ^ v/ , "" ). split ( "-" )[ 0 ]. split ( "." ). map (( n ) => Number. parseInt (n, 10 ) || 0 );
const [ a1 = 0 , a2 = 0 , a3 = 0 ] = parse (a);
const [ b1 = 0 , b2 = 0 , b3 = 0 ] = parse (b);
return a1 - b1 || a2 - b2 || a3 - b3;
}
この実装で私が長く間違えていたのが、ロールアウトの比較演算子と、端末バケットの求め方です。
バケットは 0 から 99 の値を返します。userBucket > rollout と書くと、rollout が 0 のときでもバケット 0 の端末には配信されてしまいます。段階配信を「まず 0% で置いてから開ける」運用にしていたので、開けていないはずの版が 1% の利用者に届いていました。>= に直すだけの話ですが、気づくまでに二回のリリースを要しました。
もう一つは、x-client-id が無いときに crypto.randomUUID() へ落としていたことです。これだと同じ端末が起動のたびに別のバケットに入り、段階配信が「毎回くじを引き直す」挙動になります。10% の段階でも、更新確認を十回行えば誰かは当たります。今はヘッダを必須にして、無ければ 400 を返しています。配らないことを選べるのが段階配信の価値だからです。
204(No Content)の扱いも整理しておきます。tauri-plugin-updater は 204 を「更新なし」として正常に処理し、それ以外の非 2xx をエラーとして扱います。つまり 204 は黙って何もしないための正規の返し方であり、私が Worker 内部のエラーでも 204 を返しているのは、利用者に無意味なダイアログを見せないためです。
ただしここには代償があります。内部エラーまで 204 に丸めると、配信が壊れていることに誰も気づけません。私は 204 を返したうえで、Worker 側では必ずログを残し、内部エラー件数のしきい値でアラートを出しています。利用者に黙るのと、自分が気づかないのは別のことです。
期待される動作としては、新しいバージョンがある場合に Tauri Updater が JSON を受け取り、URL のバイナリを署名検証つきでダウンロードしてインストールに進みます。ロールアウト率を 10 → 50 → 100 と段階的に上げるオペレーションは、KV の値を更新するだけで完了します。
ロールバック手順も忘れずに設計してください。私は「直前バージョンのマニフェスト JSON を previous_meta キーに常時保管しておき、緊急時は KV のキーを書き換えるだけで切り戻せる」運用にしています。R2 のバイナリは削除せず、URL も変えないことが鉄則です。
ライセンスキー検証システム — オフライン対応の実装パターン
有料デスクトップアプリで悩ましいのが「ネット接続がない環境でもアプリは動かしたいが、海賊版対策もしたい」という相反する要件です。私が採用しているのは、サーバ署名 + クライアント検証のハイブリッド方式です。
仕組みはこうです。Stripe で決済が完了したら、サーバ側で「ユーザー ID + 有効期限 + デバイス指紋ハッシュ」を秘密鍵で署名したライセンスキーを発行します。アプリ側は対応する公開鍵をバイナリに埋め込んでおき、起動時に署名検証だけを行います。これによりオフラインでも検証が可能で、サーバが落ちていてもアプリは起動します。
// src-tauri/src/license.rs - Ed25519 オフラインライセンス検証
// URL セーフ・パディング無しにしておくと、メール本文で折り返されても
// URL に貼り付けられても壊れず、サポート対応が減る
use base64 :: { engine :: general_purpose :: URL_SAFE_NO_PAD 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))
}
署名検証を先に済ませ、期限とデバイス指紋の判定を後に置いている順番にも理由があります。ペイロードは署名で守られていて初めて信用できる値になるので、検証前の JSON を条件分岐に使ってはいけません。順番を入れ替えても動いてしまうぶん、レビューで見落としやすい箇所です。
もう一点、この方式は expires_at の判定に端末の時計を信用しています。年額プランで時計を巻き戻されると、期限切れのライセンスが通ってしまいます。私は OS のキーチェーンに「前回起動時に観測した最大の時刻」を書いておき、それより過去の時刻を見たら期限切れとして扱う単調性チェックを足しました。買い切りプランしか売っていないなら不要ですが、年額を混ぜるなら入れておくほうが後で楽です。
この実装で重要なのは、デバイス指紋の取得方法です。MAC アドレスは仮想 NIC で簡単に偽装できますし、ハードディスクのシリアル番号は dual-boot 環境で破綻します。私は OS のインストール時に生成される UUID(macOS なら IOPlatformUUID、Windows なら MachineGuid)を組み合わせ、SHA256 でハッシュ化して使っています。これでも完全な対策ではありませんが、家族間でのカジュアルなコピーは防げますし、本気の海賊版とは別問題と割り切る判断も時には必要です。
なぜサーバ側でライセンス無効化リストを持たないのかという議論もありますが、私はあえて持っていません。返金処理の自動化が複雑になり、ライセンス検証のためのオンライン依存が生まれ、結果としてユーザー体験を損なうからです。重大な悪用を見つけた場合だけ、次のアップデートで該当ライセンスのブラックリストをバイナリに含めるという運用にしています。
Stripe Checkout からライセンス発行までを Worker 1 本に収める
ライセンス発行を Stripe Webhook と連動させる部分が、もう一つの設計の山場です。文章にすると数行で済むのに、実装すると署名検証と冪等化の両方でつまずきます。実際に動かしている Worker をそのまま載せます。
// worker/webhook.ts - Stripe Webhook を受けて Ed25519 ライセンスを発行する
interface Env {
LICENSES_KV : KVNamespace ;
STRIPE_WEBHOOK_SECRET : string ;
LICENSE_SIGNING_KEY : string ; // PKCS#8 の Ed25519 秘密鍵(base64)
}
interface Claims {
user_id : string ;
email : string ;
plan : "lifetime" | "annual" ;
device_fingerprint : string ;
}
export default {
async fetch ( req : Request , env : Env ) : Promise < Response > {
const raw = await req. text (); // 署名検証は生ボディに対して行う。JSON にしてからでは通らない
const ok = await verifyStripeSignature (raw, req.headers. get ( "stripe-signature" ) ?? "" , env. STRIPE_WEBHOOK_SECRET );
if ( ! ok) return new Response ( "Invalid signature" , { status: 400 });
const event = JSON . parse (raw);
if (event.type !== "checkout.session.completed" ) {
return new Response ( "Ignored" , { status: 200 });
}
// 冪等化: 同じ event.id を二度処理しない
const guard = `evt:${ event . id }` ;
if ( await env. LICENSES_KV . get (guard)) {
return new Response ( "Duplicate" , { status: 200 });
}
await env. LICENSES_KV . put (guard, "1" , { expirationTtl: 60 * 60 * 24 * 30 });
const session = event.data.object;
const email = session.customer_details?.email ?? "" ;
const license = await issueLicense (env, {
user_id: session.client_reference_id ?? session.id,
email,
plan: session.metadata?.plan === "annual" ? "annual" : "lifetime" ,
device_fingerprint: session.metadata?.device_fingerprint ?? "" ,
});
// 再送できるよう、発行済みライセンスは必ず保存しておく
await env. LICENSES_KV . put ( `license:${ email }` , license);
return new Response ( "OK" , { status: 200 });
} ,
} ;
async function issueLicense ( env : Env , claims : Claims ) : Promise < string > {
const expires_at =
claims.plan === "annual" ? Math. floor (Date. now () / 1000 ) + 60 * 60 * 24 * 365 : 0 ;
const payload = new TextEncoder (). encode ( JSON . stringify ({ ... claims, expires_at }));
const key = await crypto.subtle. importKey (
"pkcs8" ,
b64ToBytes (env. LICENSE_SIGNING_KEY ),
{ name: "Ed25519" },
false ,
[ "sign" ],
);
const sig = new Uint8Array ( await crypto.subtle. sign ( "Ed25519" , key, payload));
return `${ b64url ( payload ) }.${ b64url ( sig ) }` ;
}
async function verifyStripeSignature ( raw : string , header : string , secret : string ) : Promise < boolean > {
const parts = Object. fromEntries (
header. split ( "," ). map (( p ) => p. split ( "=" ) as [ string , string ]),
);
const t = parts.t;
const v1 = parts.v1;
if ( ! t || ! v1) return false ;
// 5 分より古い署名は再送として捨てる
if (Math. abs (Date. now () / 1000 - Number (t)) > 300 ) return false ;
const key = await crypto.subtle. importKey (
"raw" ,
new TextEncoder (). encode (secret),
{ name: "HMAC" , hash: "SHA-256" },
false ,
[ "sign" ],
);
const mac = await crypto.subtle. sign ( "HMAC" , key, new TextEncoder (). encode ( `${ t }.${ raw }` ));
const expected = [ ...new Uint8Array (mac)]
. map (( b ) => b. toString ( 16 ). padStart ( 2 , "0" ))
. join ( "" );
// 早期 return しない比較にする
if (expected. length !== v1. length ) return false ;
let diff = 0 ;
for ( let i = 0 ; i < expected. length ; i ++ ) diff |= expected. charCodeAt (i) ^ v1. charCodeAt (i);
return diff === 0 ;
}
function b64url ( bytes : Uint8Array ) : string {
return btoa (String. fromCharCode ( ... bytes))
. replace ( / \+ / g , "-" )
. replace ( / \/ / g , "_" )
. replace ( /= +$ / , "" );
}
function b64ToBytes ( b64 : string ) : Uint8Array {
return Uint8Array. from ( atob (b64), ( c ) => c. charCodeAt ( 0 ));
}
b64url が Rust 側の URL_SAFE_NO_PAD と対になっている点が要です。発行側と検証側で base64 の方言がずれると、署名は正しいのに検証が落ちるという追いにくい不具合になります。私はここで半日を溶かしました。
Ed25519 は Workers の WebCrypto から使えます。古い compatibility_date では NODE-ED25519 という名前だったため、importKey が NotSupportedError を返す場合は wrangler の設定日を先に確認してください。
冪等化については、正直に限界も書いておきます。KV は結果整合なので、同じイベントがほぼ同時に二回届いた場合、両方が get で空を読む可能性が残ります。金額の大きい商品を扱うなら Durable Object で直列化するのが正解です。私の規模では Stripe の再送間隔が十分に空いているため KV で足りていますが、これは「今の売上規模だから許容している」判断であって、設計上正しいからではありません。
ライセンスメールは決済直後だけでなく、利用者が再ダウンロードしたいときにも再送できる形にしてあります。license:{email} を保存しているのはそのためで、サポートに届く問い合わせの大半は「メールを失くした」なので、ここが自動化されているかどうかで手間がまるで変わります。
決済画面のデザインや Stripe Checkout のロケール対応については、Antigravity で収益化アプリ開発 — Stripe 課金実装からストア公開・月額収益化まで で扱っています。本記事の Tauri 用ライセンス発行ロジックと組み合わせれば、商用配布の枠組みは一通り揃います。
リリースを 1 本のパイプラインに並べる
ここまでの部品を、実行順に並べ直します。順番を間違えると個々の実装が正しくても壊れるので、私は CI の定義そのものを設計文書として扱っています。
# .github/workflows/release.yml(順番が要点なので macOS ジョブのみ抜粋)
jobs :
macos :
runs-on : macos-14
steps :
- uses : actions/checkout@v4
# 1. DMG は作らせない。署名前の .app が同梱された DMG を作らせないため
- name : Build (.app only)
run : |
npm ci
npm run tauri build -- --target universal-apple-darwin --bundles app
# 2. 署名 → DMG 再作成 → 公証 → staple
- name : Sign and notarize
env :
APPLE_ID : ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID : ${{ secrets.APPLE_TEAM_ID }}
APPLE_APP_PASSWORD : ${{ secrets.APPLE_APP_PASSWORD }}
SIGNING_IDENTITY : ${{ secrets.SIGNING_IDENTITY }}
run : ./scripts/notarize.sh
# 3. 更新マニフェスト用の署名は、公証済みの成果物に対して行う
- name : Sign update artifact
env :
TAURI_SIGNING_PRIVATE_KEY : ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD : ${{ secrets.TAURI_SIGNING_KEY_PASSWORD }}
run : npx @tauri-apps/cli signer sign dist/MyApp_universal.dmg
# 4. R2 へ置き、ロールアウトは 0% で登録する(開けるのは人間の判断)
- name : Publish at 0%
run : ./scripts/publish.sh --rollout 0
要点は二つあります。
一つ目は --bundles app です。tauri build に DMG まで作らせてしまうと、署名前の .app が入った DMG が必ず先に生まれます。作らせなければ、間違ったほうを配る事故そのものが起きません。
二つ目は、最後の公開を 0% で登録していることです。以前は 10% から始めていましたが、配信直後の 30 分は「本当にダウンロードできるか」を自分で確かめる時間に使いたい、という理由で 0% に変えました。ロールアウトは KV の値を書き換えるだけなので、確認が済んでから開けても手間は増えません。
更新マニフェストの署名を公証のあとに置いている点も意図的です。公証と staple は成果物のバイトを変更するため、先に署名しておくとハッシュが合わなくなります。この順番だけは入れ替えられません。
よくある間違いと落とし穴
実運用で実際に詰まったポイントを共有していきます。これらは公式ドキュメントに書かれていない、もしくは目立たない場所にしか書かれていない落とし穴です。
第一の落とし穴は、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 課金実装からストア公開・月額収益化まで と本記事を行き来してみてください。あちらが売る側の導線、こちらが売ったあとに届ける側の話になります。
私自身、ここに書いた誤りを全部自分で踏んでから直しています。同じ半日を誰かが節約できたなら、書き残した甲斐があります。お読みいただきありがとうございました。