Tauri Updater権限はdefault.jsonではなくruntimeでgateする
Tauri v2のpermissionsは意図的に明示的です。これはsecurity上はよい設計ですが、optional pluginでは罠になります。
updater featureがoptionalなのに、src-tauri/capabilities/default.jsonへupdater permissionを入れると、default buildでも未compileまたは未初期化のplugin向けpermissionを読み込むことがあります。結果として、feature-off buildがupdater capability設定に依存してしまいます。
安全なpatternは、default.jsonをfeature-agnosticに保ち、updater buildのときだけupdater permissionを追加することです。
症状: feature-off buildでもupdater permissionが読み込まれる
壊れた設定は、最初は自然に見えます。
{
"identifier": "default",
"windows": ["main"],
"permissions": [
"core:default",
"updater:default",
"process:allow-restart"
]
}
その後、default buildをupdaterなしで実行します。
npm run tauri -- build
しかし、application startupやcapability validationはupdater関連permissionをまだ見ています。pluginやTauri versionによって、startup error、permission error、frontendからplugin commandが使えないerrorとして表面化します。
問題はpermissionそのものではありません。optional featureをdefault capability setの一部にしていることです。
static capabilityはCargo cfg gateに従わない
Rust codeは#[cfg(feature = "updater")]で除外できます。
Capability JSONはdataです。Tauriがconfigurationとして読みます。そのdataをdefault pathから外さない限り、Rustのcompile-time gateには自動では従いません。
つまり、次の2つが同時に起きます。
- updater Rust codeはcompileされていない
- 読み込まれるcapability fileにはupdater permission stringが残っている
optional pluginでは、base permission fileを退屈なくらい小さく保ちます。常に有効なapplication surfaceだけを書くべきです。
updater capability fileを分ける
updater専用permissionを別ファイルへ移します。
{
"identifier": "updater-runtime",
"windows": ["main"],
"permissions": [
"updater:default",
"process:allow-restart"
]
}
必要なpermissionは、updateの適用方法によって変わります。frontendが@tauri-apps/plugin-processのrelaunch()を呼ぶならrestart permissionが必要です。Rust側がapp handleから直接restartするなら、frontend commandには不要な場合もあります。
大事なのはpermission listの細部ではなく、このfileがすべてのbuildで読み込まれないことです。
updater buildだけでcapabilityをinjectする
Rustでcapability injectionをgateします。
#[cfg(feature = "updater")]
fn init_updater_capability(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::Error>> {
let capability = include_str!("../updater-capability.json");
app.add_capability(capability)?;
Ok(())
}
#[cfg(not(feature = "updater"))]
fn init_updater_capability(_app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
setupから呼びます。
tauri::Builder::default()
.setup(|app| {
init_updater_capability(app.handle())?;
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
これでsetup pathは共通になります。default buildではno-op、updater buildでは追加capabilityをinjectします。
frontendのupdater呼び出しも同じ境界内に置く
permissionだけでは不十分です。frontendがupdater APIを無条件importしていると、feature-off buildでruntimeまたはbundle時に壊れる可能性があります。
小さなwrapperを作ります。
export async function checkForAppUpdate() {
try {
const { check } = await import("@tauri-apps/plugin-updater");
return await check();
} catch {
return null;
}
}
UIの他の部分は、pluginを直接importせずcheckForAppUpdate()を呼びます。
これはrelease errorを隠すためではありません。release buildにはupdater専用のsmoke testが必要です。ここで防ぎたいのは、default buildがoptional pluginに結合してしまうことです。
両方のbuildがcleanに起動することを確認する
両variantを実行します。
npm run tauri -- build
npm run tauri -- build --features updater --config src-tauri/tauri.updater.conf.json
確認する期待値は次です。
- default buildがupdater capability errorなしで起動する
- default buildが「automatic updatesが有効」と誤解させるUIを出さない
- updater buildでupdater permissionが使える
- updater buildでupdate check pathを呼べる
- restart/relaunch動作が、実際に使うprocess permissionで覆われている
updater buildだけをtestするとdefault buildの破損を見逃します。default buildだけをtestするとrelease updater pathのpermission不足を見逃します。
security boundaryを見える形に保つ
capability injectionを単なるworkaroundとして扱わないでください。これはsecurity boundaryです。
base capability fileは「アプリが常にできること」を答えるべきです。
updater capabilityは「自分自身を更新できるbuildだけに必要な追加権限」を答えるべきです。
この分離があるとreviewしやすくなります。updater不要のbuildにpermissionが静かに付与されていないことを確認できます。
参考
- Tauri updater plugin
- Tauri process plugin permissions
- Tauri v2 Updaterビルドは設定overlayを分ける
- Tauriデスクトップアプリのライセンス認証はCloudflare Workerでプロキシする
まとめ
optional updater permissionをdefault capability fileに入れないでください。常に有効なcapability setを小さく保ち、updater buildだけでpermissionをinjectし、両方のbuild variantを確認します。これでsecurity modelが読みやすくなり、feature-off startupの意外な失敗も減ります。