← ./articles-ja

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-processrelaunch()を呼ぶなら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が静かに付与されていないことを確認できます。

参考

まとめ

optional updater permissionをdefault capability fileに入れないでください。常に有効なcapability setを小さく保ち、updater buildだけでpermissionをinjectし、両方のbuild variantを確認します。これでsecurity modelが読みやすくなり、feature-off startupの意外な失敗も減ります。