Skip to content

Repository files navigation

tauri-plugin-updater-delta

A Tauri demo app updating from 1.3.0 to 1.4.0 with a 714 KB delta instead of the 15.9 MB full download

A small demo app taking a 1.3.0 → 1.4.0 update as a 714 KB delta instead of a 15.9 MB full download. The ratio depends on what changed between releases.

Tauri's updater re-downloads the whole installer on every release. This plugin downloads only what changed. Same manifest, same signing key, and when a delta can't be used it falls back to a normal full download.

  • Windows x86_64 NSIS and macOS .app.tar.gz
  • Benchmark with ~6 MiB of new media: 7.0 MB instead of 106 MB on Windows, 6.9 MB instead of 78 MB on macOS (research F42)
  • Signature re-verified, downgrades refused, untrusted cache re-checked
  • Two .plugin(...) lines in your app plus a release step → Quickstart

Differential updates for Tauri v2. The plugin keeps Tauri's official update check and installer, but may reconstruct the exact published artifact from a smaller patch before handing it to tauri-plugin-updater.

This is pre-release software. The supported v0.2 paths are macOS .app.tar.gz and Windows x86_64 NSIS -setup.exe. Linux, Windows MSI, and Windows ARM64 client support are not claimed, and the final security audit and release gate have not happened yet.

What is proven

  • A real macOS app completed 1.0.0 → 1.0.1 via Full, relaunched with its verified artifact ACTIVE, then completed 1.0.1 → 1.0.2 via TarDelta through the real Tauri installer.
  • The harness asserts the update source and exact installed binary hashes, so a Full fallback cannot masquerade as a delta success.
  • A real Windows x86_64 NSIS installation completed 1.0.0 -> 1.0.1 via Full, promoted only after relaunch, then completed 1.0.1 -> 1.0.2 via DirectDelta. A corrupt cached installer degraded to Full and still installed the exact expected executable.
  • With the installer hook, a real Windows NSIS installation with an empty cache took its first update as DirectDelta from the installer kept at install time, and the updater's own install refreshed that copy (research F46).
  • The original solid-LZMA NSIS experiment produced patches that were 98.2520% and 98.2523% of Full. The Windows updater artifact now disables NSIS compression so unchanged bytes remain reusable, and the release tool refuses to publish a direct patch unless it is strictly below 30% of Full.
  • On controlled example-app pairs, a direct compressed-artifact patch was 95.5–96.1% of a Full download while a tar-layer patch was 16.0–16.1% (release-candidate build, 2026-08-14; earlier controlled runs measured 15.0–15.4% for the tar layer). These are measurements of those builds, whose only difference is a version string. A real application's ratio depends entirely on what changed between releases and will differ.
  • Final artifact signature verification, authenticated release identity, bounded reconstruction, cache re-verification, and release-time patch round-trips are enforced and tested.

Those ratios come from releases that differ by little more than a version string. On a benchmark app carrying ~88 MiB of bundled assets, with a feature-sized change that replaces and adds about 6 MiB of incompressible media and edits some data, the download was 6.6% of Full on Windows (7.0 of 106 MB) and 8.8% on macOS (6.9 of 78 MB); a client two releases behind paid about the same (research F42). That is one controlled payload, not a promise: a real application's ratio depends on how much of it changes.

On Windows a first update can be a delta too, when the app ships the installer hook below, and a Full download is served from a zstd copy of about LZMA size (research F45, F46).

GitHub-hosted HTTPS Full→TarDelta, Apple Developer ID/notarized, and Windows Authenticode-signed end-to-end tests remain credential-bound validation gaps. See Releasing and the evidence ledger in research/FINDINGS.md.

Quickstart

1. Prerequisites

  • Rust 1.88 or newer.
  • A Tauri v2 application already using updater artifacts: a macOS .app.tar.gz and/or a Windows x86_64 NSIS -setup.exe.
  • tauri-plugin-updater 2.10.1 or 2.11.0. The delta plugin deliberately supports >=2.10.1, <2.12.0 because its security assumptions were verified by reading those two upstream implementations. 2.12.0 is not supported yet; see Decisions #39.
  • An HTTPS location for manifest.json, the full artifacts, and patches.
  • A Tauri updater signing key. Keep the private key out of source control.

Add both plugins from crates.io:

cargo add tauri-plugin-updater@=2.10.1
cargo add tauri-plugin-updater-delta

2. Register both plugins

fn main() {
    tauri::Builder::default()
        .plugin(tauri_plugin_updater::Builder::new().build())
        .plugin(tauri_plugin_updater_delta::Builder::new().build())
        .run(tauri::generate_context!())
        .expect("error while running the Tauri application");
}

Builder::new() is the normal configuration. It derives the app identifier, updater public key, platform, architecture, cache namespace, cache location, transaction location, transport policy, and local safety ceilings.

3. Configure Tauri's updater

The existing Tauri updater configuration remains authoritative:

{
  "bundle": {
    "createUpdaterArtifacts": true
  },
  "plugins": {
    "updater": {
      "endpoints": [
        "https://releases.example.com/{{target}}/{{arch}}/manifest.json"
      ],
      "pubkey": "YOUR_BASE64_TAURI_MINISIGN_PUBLIC_KEY"
    }
  }
}

For Windows NSIS updater artifacts, use a platform configuration such as tauri.windows.conf.json to disable solid installer compression:

{
  "bundle": {
    "windows": {
      "nsis": {
        "compression": "none"
      }
    }
  }
}

This trades a larger Full updater artifact for much smaller later deltas. Pass --compressed-full-out and --compressed-full-url to delta-release as well: plugin clients that need the whole installer then download a zstd copy and rebuild the exact installer from it, so they do not pay for the larger Full artifact. Stock Tauri clients keep using the uncompressed URL.

To make the first Windows update a delta too, ship the installer hook from examples/desktop-app/windows/delta-seed.nsh ("installerHooks" in the same nsis block). It keeps a copy of the installer in the install directory; the plugin uses it as the patch base only if it matches the base a patch declares (Decisions #41). The first update from an older solid-LZMA release may use Full; that successful update seeds the delta-friendly installer used as the next release's base.

The public key must be present in tauri.conf.json; the delta plugin reads the same configured value. There is no delta-specific manifest URL and no second manifest request.

4. Check and install

use tauri_plugin_updater_delta::{DeltaUpdaterExt, Outcome};

async fn check_for_updates(app: tauri::AppHandle) -> Result<String, String> {
    let Some(update) = app
        .delta_updater()
        .check()
        .await
        .map_err(|error| error.to_string())?
    else {
        return Ok("up-to-date".to_owned());
    };

    let outcome = update
        .install()
        .await
        .map_err(|error| error.to_string())?;

    for diagnostic in outcome.diagnostics() {
        // Installation succeeded. This warning means a future update may need
        // Full again because the cache was unavailable or could not be written.
        log::warn!("{diagnostic}");
    }

    Ok(match outcome {
        Outcome::InstalledFromFullDownload { .. } => "installed-full",
        Outcome::InstalledFromDirectDelta { .. } => "installed-direct-delta",
        Outcome::InstalledFromTarDelta { .. } => "installed-tar-delta",
        Outcome::UpToDate { .. } => "up-to-date",
        _ => "installed",
    }
    .to_owned())
}

For a frontend button, expose that Rust function as a normal #[tauri::command] and invoke it from the frontend. v0.2 is intentionally Rust-first; it does not add a second JS/TS updater SDK or expose identity, cache, and verification internals to frontend code. The complete version is examples/desktop-app/src/update.rs.

check_with and check_and_install_with accept a callback for truthful coarse phases: Checking, Downloading, Reconstructing, Verifying, Installing, and Finished. Downloads also report DownloadProgress { downloaded, total } byte counts, where total is the server's advertised length when it sent one. The other phases report no percentage, because the underlying work cannot report one accurately. An Err is the failure signal.

A successful Outcome reports what the update cost: downloaded_bytes(), full_artifact_size() and bytes_saved() (zero for a Full download).

5. Produce release artifacts

Install the release tool:

cargo install --locked tauri-updater-delta-release

Use the same private key Tauri uses:

export TAURI_SIGNING_PRIVATE_KEY="$(cat /secure/path/my-app.key)"
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="your-real-password"

The password must be a real one. A key generated with tauri signer generate --password "" cannot be read by this tool at all: Tauri's key generation encrypts even with an empty password, and the minisign crate used here does not. The failure is reported clearly, but it is easier to avoid than to diagnose — generate the key with a password you actually keep.

For the first published updater release, omit predecessor flags. This produces a valid signed Full-only manifest.json:

delta-release \
  --app-id com.example.myapp \
  --platform darwin-aarch64 \
  --target-version 1.0.1 \
  --new-installer dist/MyApp.app.tar.gz \
  --installer-url https://releases.example.com/v1.0.1/MyApp.app.tar.gz \
  --signature-out dist/MyApp.app.tar.gz.sig \
  --manifest dist/manifest.json

For a later release, repeat the predecessor flags to add direct-to-current patches from every previous version you want to retain. On macOS, add one tar-layer output pair for each predecessor in the same order:

delta-release \
  --app-id com.example.myapp \
  --platform darwin-aarch64 \
  --target-version 1.0.3 \
  --new-installer dist/MyApp.app.tar.gz \
  --installer-url https://releases.example.com/v1.0.3/MyApp.app.tar.gz \
  --from-version 1.0.2 \
  --previous-installer dist/MyApp-1.0.2.app.tar.gz \
  --patch-url https://releases.example.com/v1.0.3/1.0.2-to-1.0.3.zst \
  --patch-out dist/1.0.2-to-1.0.3.zst \
  --tar-patch-url https://releases.example.com/v1.0.3/1.0.2-to-1.0.3.tar.zst \
  --tar-patch-out dist/1.0.2-to-1.0.3.tar.zst \
  --from-version 1.0.1 \
  --previous-installer dist/MyApp-1.0.1.app.tar.gz \
  --patch-url https://releases.example.com/v1.0.3/1.0.1-to-1.0.3.zst \
  --patch-out dist/1.0.1-to-1.0.3.zst \
  --tar-patch-url https://releases.example.com/v1.0.3/1.0.1-to-1.0.3.tar.zst \
  --tar-patch-out dist/1.0.1-to-1.0.3.tar.zst \
  --require-tar-layer \
  --signature-out dist/MyApp.app.tar.gz.sig \
  --manifest dist/manifest.json

For a Windows NSIS release, fold the windows-x86_64 entry into the same manifest.json. There is no tar layer on Windows, so omit the --tar-patch-* flags and never pass --require-tar-layer:

delta-release \
  --app-id com.example.myapp \
  --platform windows-x86_64 \
  --target-version 1.0.2 \
  --from-version 1.0.1 \
  --previous-installer dist/MyApp_1.0.1_x64-setup.exe \
  --new-installer dist/MyApp_1.0.2_x64-setup.exe \
  --installer-url https://releases.example.com/v1.0.2/MyApp_1.0.2_x64-setup.exe \
  --patch-url https://releases.example.com/v1.0.2/1.0.1-to-1.0.2-windows.zst \
  --patch-out dist/1.0.1-to-1.0.2-windows.zst \
  --signature-out dist/MyApp_1.0.2_x64-setup.exe.sig \
  --manifest dist/manifest.json

The four direct-patch predecessor flags must be repeated the same number of times, in matching order. The two tar-patch flags are either omitted or repeated once per predecessor. Every direct and tar patch is applied and checked before its metadata is published.

Each direct patch is published only when it is strictly below 30% of the full installer (--max-direct-patch-percent). Oversized patches are deleted independently: other qualifying versions retain their patches, while a client on an omitted version safely uses Full.

Use darwin-x86_64 for an Intel build. --app-id, platform, and target version are explicit because they enter the cryptographically authenticated release identity; guessing them would hide a real security decision. The release tool derives artifact digest and size itself and applies every generated patch before publishing its metadata.

The asset set and pre-upload verification command are documented in docs/RELEASING.md.

First update and fallback behavior

A fresh installation has no official updater artifact cached:

first update after adoption or cache miss -> Full -> stage PENDING
updated app launches                    -> promote to ACTIVE
later compatible update                 -> TarDelta when published and valid

On macOS the first update also tries to rebuild the previous release's tar from the installed .app and use it as the TarDelta base, but only if it matches the published base exactly. Otherwise it takes Full as above. A real DMG install on a macOS CI runner matched and took its first update as a TarDelta (research F39). That runner built and installed the app as the same user; on a machine where the file owner or layout differs, the bundle will not match and the first update is Full, so treat this as a likely saving rather than a guarantee (Decisions #37).

Differential updates are an optimization, not a promise for every release. A missing/corrupt cache, missing patch, download failure, unsupported patch, or reconstruction mismatch safely degrades to Full. Cache persistence failures are returned as non-fatal Diagnostic values on the successful Outcome; they do not turn a correct install into a failure.

Authenticated release-identity contradictions, downgrades, and final signature failures are different: they fail closed and install nothing. Legacy signatures without a delta-v1 identity remain Full-compatible but cannot use DirectDelta or TarDelta.

Advanced configuration

Most apps need none. Builder permits explicit overrides for cache/work paths, download limits, timeouts, redirect count, reconstruction Limits, and CacheLimits. Server metadata can never raise those local ceilings.

Plain HTTP and explicit endpoint/base overrides exist only under the non-default test-support feature used by this repository's E2E harness. They are absent from a normal build.

Supported versions (v0.2)

Everything below is a constraint the code actually enforces. crates/plugin/tests/supported_versions.rs checks this table against the dependency constraints, workspace fields, pinned CI environment and engine constants, so it fails rather than drifts.

v0.2
Client platform macOS .app.tar.gz; Windows NSIS -setup.exe
Architecture macOS aarch64 and Windows x86_64 demonstrated; Intel macOS and Windows ARM64 not demonstrated
Rust 1.88 or newer
tauri 2
tauri-plugin-updater >=2.10.1, <2.12.0
Release bundler tauri-cli 2.10.1 (exact)
Artifact representation macOS app-tar-gz-v1; Windows opaque-v1
Recompression recipe macOS tauri-app-tar-gz-v1 (tar 0.4.x into flate2 with the zlib-rs backend); none for NSIS
Delta backend zstd

The updater range is narrow on purpose: six behaviours this plugin's safety depends on are observations about tauri-plugin-updater's implementation rather than guarantees of its API. They were read in 2.10.1 and again, site by site, in 2.11.0. A caret range would let any of them change while CI stayed green. 2.12.0 changes one of them: its verifier also reads a signed version from the trusted comment, where this plugin keeps its release identity. That needs a design decision before it can be admitted (Decisions #39).

Pin =2.10.1 if you want exactly the configuration this release tested. The strongest evidence here is against that one version: its source was read for each of the six behaviours, the real-install E2E runs ran against it, and a test asserts this repository's lockfile resolves to it. 2.11.0 is supported on the strength of the same source reading, and the upstream updater / range-max CI job runs the plugin's suite against it; no real-install E2E has used it. The consequences of drift are degradation rather than compromise — this plugin performs its own signature and identity verification regardless of upstream, so a changed upstream behaviour costs the delta path and falls back to a full download rather than weakening what gets installed — but if you want the tested configuration, pin it:

tauri-plugin-updater = "=2.10.1"

On Intel macOS. The engine is byte-oriented and architecture-independent, and the same engine and release tests run on Linux and Windows x86_64 in CI. The recompression recipe depends on tar and flate2 behaviour rather than on the CPU. So x86_64 macOS is expected to work — but no real application install has been performed there, so it is not claimed as demonstrated. A client that meets a representation or recipe it does not implement declines the tar layer and downloads in full rather than guessing.

Compatibility and security scope

  • The plugin always installs through the exact Tauri Update returned by the one authoritative check. Applications cannot construct that binding or a verified install handoff.
  • Cache contents are untrusted: kind, size, digest, and signature are checked on reuse. A successful install stages PENDING; only observing that version on a later launch promotes it to ACTIVE.
  • The release identity format and security rationale live in docs/DECISIONS.md. Implementation flow and resource boundaries live in docs/ARCHITECTURE.md.
  • The manifest itself is not authenticated and release freshness is not proven. This is not a TUF-style update framework.
  • No telemetry is added. The plugin contacts only the updater and artifact URLs selected from the application's existing updater response.

Project documentation

License

MIT — see LICENSE.

Releases

Packages

Contributors

Languages