Updates
Vidra apps update in two tiers. Use either, both, or neither.
| Ships | Mechanism | Needs | |
|---|---|---|---|
| Over-the-air | your ui/ build | a new bundle, applied on the next launch | nothing extra |
| Native | the whole app, native code included | Velopack replaces the install in place | Vidra.Updates.Native + vpk |
Web bundles are small, need no installer, and can never get ahead of the binary: they install only when their generated contract fingerprints match the running host. Native updates are for releases that change C#.
Both update tiers are off until configured, and both read one vidra.updates
block.
Turn updates on
Opt in from MauiProgram.cs:
builder
.UseMauiApp<App>()
.UseVidra()
.UseVidraUpdates();
Then configure a feed in the app’s package.json:
{
"vidra": {
"updates": {
"feedUrl": "https://updates.example.com/bundles.json"
}
}
}
vidra build stamps that block into the app. Optional keys include channel,
which filters feed entries, and enabled: false, a kill switch that still lets
an already downloaded bundle finish promoting.
Publish a web bundle
npm version patch
npx vidra bundle --merge-from https://updates.example.com/bundles.json
This builds ui/, writes dist/bundle-<version>-<hash>.zip, and produces a
dist/bundles.json containing the new entry plus everything already published.
Each entry contains generated core and app contract fingerprints.
Always merge the live index
--merge-from fetches the index you are serving before adding the release.
Without it, a clean CI checkout would publish an index containing only the newest
entry.
Older entries matter: when a native release changes a contract, older installed apps can only accept the older compatible bundles. If the live index cannot be fetched, publishing fails rather than silently starting empty.
When signing is enabled, vidra bundle also verifies that the index being merged
was signed by a trusted key.
Upload in the right order
Two rules apply to every host:
- Upload archives first, then
bundles.jsonand its signature. Otherwise a client can see an index that names an archive not yet available. - Never delete old archives during sync. Older installs may still need them.
S3-compatible storage:
aws s3 cp dist/ s3://$BUCKET/stable/ --recursive --exclude "bundles.json*" \
--cache-control "public, max-age=31536000, immutable"
aws s3 cp dist/bundles.json s3://$BUCKET/stable/ --cache-control "no-cache"
aws s3 cp dist/bundles.json.sig s3://$BUCKET/stable/ --cache-control "no-cache"
GitHub Releases can use a fixed tag:
gh release upload updates dist/bundle-*.zip --clobber
gh release upload updates dist/bundles.json dist/bundles.json.sig --clobber
# feedUrl: https://github.com/<owner>/<repo>/releases/download/updates/bundles.json
Cloudflare R2 via Wrangler:
for f in dist/bundle-*.zip; do wrangler r2 object put "$BUCKET/stable/$(basename "$f")" --file "$f"; done
wrangler r2 object put "$BUCKET/stable/bundles.json" --file dist/bundles.json --cache-control "no-cache"
wrangler r2 object put "$BUCKET/stable/bundles.json.sig" --file dist/bundles.json.sig --cache-control "no-cache"
Archive names contain a hash and can cache forever. The index should use
no-cache so rollbacks reach clients.
Publish from CI
- run: npm version ${{ inputs.bump }} --no-git-tag-version
- run: npx vidra bundle --merge-from https://updates.example.com/bundles.json
env:
VIDRA_UPDATE_SIGNING_KEY: ${{ secrets.VIDRA_UPDATE_SIGNING_KEY }}
- run: ./scripts/upload-feed.sh
Compatibility and selection
Two independent checks decide whether a bundle installs:
| Question | Answered by |
|---|---|
| May it run? | both contract fingerprints match the host |
| Should it run? | its version is greater than the current version |
The fingerprints are SHA-256 over canonical bridge manifests: one for Vidra’s core contracts and one for app contracts. A native rebuild that changes no API keeps the same fingerprint; any contract change alters it.
If you change a [BridgeModule], [BridgeEventContract], or [JsContract],
existing installs stop accepting new bundles until you ship a native release.
The host rechecks installed bundles on every launch. A bundle that no longer
matches after a native update, or is older than the embedded UI, is set aside and
the embedded copy serves instead. Rolling the native release back can make that
bundle valid again. Byte-identical bundles are skipped even if their version is
newer because vidra bundle creates deterministic archives.
Runtime lifecycle
- On launch, the host promotes a previously downloaded bundle and chooses which directory serves. The embedded bundle always remains available.
- A few seconds later, it checks the feed, downloads the newest compatible release, verifies its hash, extracts it, and stages it.
- Nothing swaps during a running session. The staged bundle is promoted on the next launch.
- A promoted bundle starts on probation. If it fails to boot twice, the app rolls back and does not retry that bundle.
State lives in <app data>/vidra/bundles/.
Sign the feed
The archive hash proves a download arrived intact. A signature over
bundles.json proves that you published the index. Sign public feeds because a
compromised feed host can otherwise serve both a modified index and matching
archives.
Generate a P-256 key:
npx vidra keygen
Add the public half to package.json and rebuild:
{
"vidra": {
"updates": {
"feedUrl": "https://updates.example.com/bundles.json",
"publicKeys": ["MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE..."]
}
}
}
Publish a signed feed:
npx vidra bundle --sign vidra-signing-key.pem
# CI:
# VIDRA_UPDATE_SIGNING_KEY="$(cat key.pem)" npx vidra bundle
Configuring any public key makes signatures mandatory. vidra bundle refuses
to publish unsigned when the app trusts a key and refuses a signing key the app
does not trust.
Rotate a key
publicKeys is a list. Add the new public key beside the old one and ship a
native release. After users receive it, begin signing with the new key. Remove
the old key in a later release.
Protect and back up the private key like a production credential. Losing it requires a native release with a new trusted key before publishing can resume.
Feeds use ECDSA P-256 with SHA-256 and a detached DER-encoded signature over the
exact bundles.json bytes.
Test a feed locally
A directory is a valid feed:
cd dist && python3 -m http.server 8099
# "feedUrl": "http://127.0.0.1:8099/bundles.json"
VIDRA_UPDATE_FEED_URL overrides the stamped feed at runtime, which is useful
for pointing an existing build at staging.
Native whole-app updates
Vidra drives Velopack for releases that change native code. Install its CLI:
dotnet tool install -g vpk
Reference the native update package:
<PackageReference Include="Vidra.Updates.Native" Version="0.4.0" />
Initialize Velopack at the start of each platform entry point and opt into the service:
// Platforms/*/Program.cs, before anything else
VelopackApp.Build().UseVidraLocator().Run();
builder.UseVidra().UseVidraNativeUpdates();
Then add a native feed directory:
{
"vidra": {
"updates": {
"feedUrl": "https://updates.example.com/bundles.json",
"native": { "feedUrl": "https://updates.example.com/app/" }
}
}
}
The web feed names a file; the native feed names the directory that vpk
writes into. Release each platform on its native OS:
npm version patch
npx vidra build --target windows --native-update
npx vidra build --target macos --native-update
Each build downloads the live feed before packing, preserving older releases and enabling deltas. Upload payloads first and the release index last.
The build keeps the default public artifact name and adds the native update outputs:
| File | Purpose |
|---|---|
dist/<App>-<version>-Setup.exe | Windows installer and recommended download |
dist/<App>-<version>-windows.zip | Velopack portable archive |
dist/<App>-<version>-macos.dmg | DMG wrapping the packed macOS app |
dist/release/ | Full packages, deltas, and releases.{channel}.json |
On Windows, -windows.zip is the Velopack portable archive copied to Vidra’s
standard artifact name. Setup.exe is only produced when --native-update is
enabled.
Important constraints:
vpkrefuses to republish an equal or lower version.- Keep full old packages; older installs and delta generation need them.
- Velopack signs packaged Windows executables using the identity Vidra resolves.
- On macOS, Velopack re-signs with
--deep. The result verifies strictly, but notarization still requires testing with your own Apple Developer identity. - Mac Catalyst needs
UseVidraLocator()because Velopack does not identify it as standard macOS. - Native and web updates do not need to coordinate. Contract checks reject a web bundle selected for an incompatible native release.
Environment reference
| Variable | Effect |
|---|---|
VIDRA_UPDATE_FEED_URL | Override the web feed URL or local directory |
VIDRA_UPDATE_CHANNEL | Override the web update channel |
VIDRA_ASSET_ROOT | Serve a directory directly and bypass updates |
VIDRA_UPDATE_SIGNING_KEY | Private signing key PEM for CI |
VIDRA_NATIVE_UPDATE_FEED_URL | Override the native feed directory |
VIDRA_NATIVE_UPDATE_CHANNEL | Override the native channel |
VIDRA_NATIVE_UPDATE_ENABLED | Set to 0 to disable native updates for one run |
VIDRA_MACOS_KEYCHAIN | Select a non-default signing keychain |
vidra dev never checks for updates; it serves the Vite development server.
Upgrade an existing Windows app
Windows web assets use https://vidra.invalid/ so downloaded and embedded
bundles share an origin. An app upgrading from a version that used MAUI’s
previous https://appdir/ origin loses origin-scoped browser state once:
localStorage, IndexedDB, cookies, and caches. The new origin remains stable
after that migration. macOS is unchanged.
Current limits
- A bundle counts as booted once its JavaScript constructs the Vidra client. Application code that throws after that point does not trigger rollback.
- Web bundles are full archives; there are no delta bundles or staged rollouts.
- Vidra does not provide an in-app update UI. Native code can inspect the running
version through
IVidraUpdates.