Skip to content

Testing OTA Updates Locally

This guide walks you through setting up a local environment to test OTA updates end-to-end — from bundle creation to signature verification to asset serving.


  • minisign for signing bundles
  • Node.js for the test server
  • Tauri CLI: cargo install tauri-cli
Terminal window
# macOS
brew install minisign
# Other platforms: see https://jedisct1.github.io/minisign/

Terminal window
minisign -G -W -p minisign.pub -s minisign.key

The -W flag skips the password prompt (fine for local testing). Keep the public key — you’ll need it for your Tauri config.

The output will show your public key:

Files signed using this key pair can be verified with the following command:
minisign -Vm <file> -P RWR+iJ9ehTe/IxJtbA0haSUz...

Create a directory with your updated frontend assets:

bundle-v1/
├── index.html
├── style.css # optional — multi-file bundles work
└── app.js # optional

Then package and sign:

Terminal window
cd bundle-v1
tar czf ../bundle-v1.tar.gz .
minisign -Sm ../bundle-v1.tar.gz -s ../minisign.key

This produces bundle-v1.tar.gz and bundle-v1.tar.gz.minisig.


Create a minimal Node.js server that implements the server contract:

server.mjs
import { createServer } from "node:http";
import { readFile, stat } from "node:fs/promises";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const PORT = 3333;
const signature = (await readFile(join(__dirname, "bundle-v1.tar.gz.minisig"), "utf-8")).trim();
const bundlePath = join(__dirname, "bundle-v1.tar.gz");
const bundleSize = (await stat(bundlePath)).size;
const server = createServer(async (req, res) => {
const url = new URL(req.url, `http://localhost:${PORT}`);
console.log(`${req.method} ${url.pathname}${url.search}`);
// Check endpoint: GET /api/ota/:currentSequence
const checkMatch = url.pathname.match(/^\/api\/ota\/(\d+)$/);
if (checkMatch) {
const currentSeq = parseInt(checkMatch[1], 10);
if (currentSeq >= 1) {
res.writeHead(204);
res.end();
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
version: "0.1.0-ota.1",
sequence: 1,
min_binary_version: "0.1.0",
url: `http://localhost:${PORT}/bundles/bundle-v1.tar.gz`,
signature,
notes: "Test OTA update",
pub_date: new Date().toISOString(),
bundle_size: bundleSize,
}));
return;
}
// Bundle download
const bundleMatch = url.pathname.match(/^\/bundles\/(.+)$/);
if (bundleMatch) {
try {
const data = await readFile(join(__dirname, bundleMatch[1]));
res.writeHead(200, {
"Content-Type": "application/gzip",
"Content-Length": data.length,
});
res.end(data);
} catch {
res.writeHead(404);
res.end("Not found");
}
return;
}
res.writeHead(404);
res.end("Not found");
});
server.listen(PORT, () => console.log(`Test server on http://localhost:${PORT}`));

Run it:

Terminal window
node server.mjs

In tauri.conf.json:

{
"plugins": {
"hotswap": {
"endpoint": "http://localhost:3333/api/ota/{{current_sequence}}",
"pubkey": "YOUR_PUBLIC_KEY_FROM_STEP_1",
"require_https": false
}
}
}

require_https: false is required for http://localhost. Never disable this in production.


Important: cargo tauri ios dev and cargo tauri android dev proxy all asset requests to the dev server and bypass the Assets trait entirely. OTA asset serving is not tested in dev mode on mobile. Always use production builds for testing.

Terminal window
cargo tauri build --debug
# Then run the binary from target/debug/
Terminal window
# Requires Xcode with a signing identity
cargo tauri ios build --debug

Install on simulator:

Terminal window
xcrun simctl install booted path/to/hotswap-example.app
xcrun simctl launch booted com.example.hotswap
Terminal window
cargo tauri android build --debug

Install on emulator:

Terminal window
# Forward the test server port to the emulator
adb reverse tcp:3333 tcp:3333
# Install and launch
adb install -r path/to/app-universal-debug.apk
adb shell am start -n com.example.hotswap/.MainActivity

  1. App starts with embedded assets (the version bundled in the binary)
  2. Check for Update — the app hits your local server and finds seq 1
  3. Apply Update — downloads the bundle, verifies the minisign signature, extracts to disk
  4. Reload — the app now serves the OTA assets from the filesystem
  5. Rollback — returns to the previous version (or embedded assets)
  6. Restart the app — OTA assets persist across restarts; if notifyReady() wasn’t called, auto-rollback kicks in