Docs Ship
Alpha documentation — APIs may change between 0.x releases.

Updates

Vidra apps update in two tiers. Use either, both, or neither.

ShipsMechanismNeeds
Web (vidra build --web)your ui/ builda new bundle, applied on the next launcha feed URL
Whole-app (vidra build --app)the whole app, native code includedVelopack replaces the install in placea feed URL + vpk

Web bundles are small, need no installer, and can never get ahead of the binary: they install only when their generated contract and bridge-access fingerprints match the running host. Whole-app updates are for releases that change C# or native access.

A plain vidra build produces the installable app and publishes every tier the config turns on. --web and --app only ever ask for less: --web skips the compile and needs no platform toolchain, --app skips the web bundle so a per-platform release job does not republish it.

Both tiers are off until updates.feed is configured in vidra.config.ts. On Android only the web tier applies; see Android.

Turn updates on

Freshly scaffolded apps already reference Vidra.Updates.Native and wire both update services into MauiProgram.cs; a feed URL is the switch. Configure one in vidra.config.ts:

import { defineConfig } from "@vidra-dev/sdk/config";

export default defineConfig({
  updates: {
    feed: "https://updates.example.com/notes/",
  },
});

Or let the CLI update the file:

npx vidra updates init --feed https://updates.example.com/notes/

A single directory serves both web and whole-app updates. Split the tiers when they live on different hosts:

updates: {
  feed: {
    web: "https://cdn.example.com/notes/",
    app: "https://downloads.example.com/notes/",
  },
}
npx vidra updates init --web https://cdn.example.com/notes/ --app https://downloads.example.com/notes/

A feed value is an https:// URL, a local directory, or github:owner/repo, which resolves to the assets of a GitHub release tagged updates (github:owner/repo@tag picks another tag).

vidra build stamps resolved feed settings into the native app. Set enabled: false to disable checks without deleting the configured feed. Run npx vidra updates with no arguments to see which tiers are on and where they point.

Channels

The release channel is a build input rather than app configuration, so the same commit can produce a stable artifact and a beta one:

npx vidra build --channel beta        # or VIDRA_CHANNEL=beta

A channel is a path segment on both sides: the build writes to dist/beta/ and the app reads from <feed>/beta/. Each channel owns its own bundles.json and releases.{platform}.json, so no index needs channel labels.

Where builds land

dist/ (or dist/<channel>/) holds deliverables: the DMG, ZIP, installer, APK, and AAB a person downloads once. The feed directory inside it is what a server serves to installed apps:

ConfigFeed directory
one feed URL, or both tiers pointing at the same URLdist/feed/
different web and app URLsdist/feed-web/ and dist/feed-app/

A feed directory mirrors exactly one remote prefix. Publishing is “sync this directory to that URL”, not a rule about which files to pick.

Publish a web bundle

npm version patch
npx vidra build --web

This builds ui/, writes bundle-<version>-<hash>.zip into the feed directory, and produces a bundles.json there containing the new entry plus everything already published. Each entry carries the generated core and app contract fingerprints plus the bridge-access fingerprint compiled from vidra.config.ts. Preview the steps with --plan.

The live index is always merged

Before adding the release, the build fetches the bundles.json currently served at the configured web feed. There is no flag to forget: a clean CI checkout cannot 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. A feed that has no index yet publishes its first one.

When signing is enabled, the build also refuses to merge an index that was not signed by your key, since re-signing it would publish someone else’s entries under your signature.

Upload in the right order

Two rules apply to every host:

  1. Upload archives first, then bundles.json and its signature. Otherwise a client can see an index that names an archive not yet available.
  2. Never delete old archives during sync. Older installs may still need them.

The examples below assume the default dist/feed/ layout.

S3-compatible storage:

aws s3 cp dist/feed/ s3://$BUCKET/notes/ --recursive --exclude "bundles.json*" \
  --cache-control "public, max-age=31536000, immutable"
aws s3 cp dist/feed/bundles.json s3://$BUCKET/notes/ --cache-control "no-cache"
aws s3 cp dist/feed/bundles.json.sig s3://$BUCKET/notes/ --cache-control "no-cache"

GitHub Releases, with the fixed updates tag that github:owner/repo reads:

gh release upload updates dist/feed/bundle-*.zip --clobber
gh release upload updates dist/feed/bundles.json dist/feed/bundles.json.sig --clobber

Cloudflare R2 via Wrangler:

for f in dist/feed/bundle-*.zip; do wrangler r2 object put "$BUCKET/notes/$(basename "$f")" --file "$f"; done
wrangler r2 object put "$BUCKET/notes/bundles.json" --file dist/feed/bundles.json --cache-control "no-cache"
wrangler r2 object put "$BUCKET/notes/bundles.json.sig" --file dist/feed/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 build --web
  env:
    VIDRA_UPDATE_SIGNING_KEY: ${{ secrets.VIDRA_UPDATE_SIGNING_KEY }}
- run: ./scripts/upload-feed.sh dist/feed

Compatibility and selection

Two independent checks decide whether a bundle installs:

QuestionAnswered by
May it run?both contract fingerprints and the access fingerprint match the host
Should it run?its version is greater than the current version

The contract fingerprints are SHA-256 over canonical bridge manifests: one for Vidra’s core contracts and one for app contracts. The access fingerprint is SHA-256 over the exact generated native policy bytes. This avoids depending on JavaScript and .NET JSON serializers producing identical output.

If you change a [BridgeModule], [BridgeEventContract], or [JsContract], or change a grant in vidra.config.ts, existing installs stop accepting new bundles until you ship a native release carrying the matching contract and policy.

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 build --web 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                      # writes vidra-signing-key.pem
# or, when turning updates on:
npx vidra updates init --feed https://updates.example.com/notes/ --keygen

updates init --keygen also adds the public half to vidra.config.ts. With a bare keygen, add it yourself and rebuild:

updates: {
  feed: "https://updates.example.com/notes/",
  publicKeys: [
    "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...",
  ],
}

Publish a signed feed:

npx vidra build --web --sign vidra-signing-key.pem
# CI:
# VIDRA_UPDATE_SIGNING_KEY="$(cat key.pem)" npx vidra build --web

Configuring any public key makes signatures mandatory. The build 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/feed && python3 -m http.server 8099
# updates: { feed: "http://127.0.0.1:8099/" }

VIDRA_UPDATE_FEED_URL overrides the stamped feed at runtime, which is useful for pointing an existing build at staging.

Whole-app updates

Vidra drives Velopack for releases that change native code. Install its CLI:

dotnet tool install -g vpk

Scaffolded projects already reference Vidra.Updates.Native (desktop targets only), call VelopackApp.Build().UseVidraLocator().Run() at the start of each desktop entry point, and opt in with .UseVidraNativeUpdates() in MauiProgram.cs. The package is inert until vidra.config.ts names an app feed, so there is nothing to retrofit when you turn the tier on later.

Release each platform on its native OS:

npm version patch
npx vidra build --app --target windows
npx vidra build --app --target macos

--app skips the web bundle so two platform jobs do not publish it twice; run vidra build --web once from any machine for the UI half, or a plain vidra build on one platform to do both. Without vpk installed, a build with an app feed configured fails and tells you how to install it.

Each build runs vpk download against the live app feed before packing, which preserves older releases and enables deltas. Upload payloads first and the release index last.

The build keeps the default public artifact name and adds the whole-app outputs:

FilePurpose
dist/<App>-<version>-Setup.exeWindows installer and recommended download
dist/<App>-<version>-windows.zipVelopack portable archive
dist/<App>-<version>-macos.dmgDMG wrapping the packed macOS app
dist/feed/ (or dist/feed-app/)Full packages, deltas, and releases.{platform}.json

On Windows, -windows.zip is the Velopack portable archive copied to Vidra’s standard artifact name. Setup.exe is only produced when an app feed is configured.

Important constraints:

  • vpk refuses 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.
  • Whole-app and web updates do not need to coordinate. Contract and access-policy checks reject a web bundle selected for an incompatible native release.

Android

Google Play owns whole-app updates on Android. Velopack is not part of the Android host. Ship native changes by uploading a new AAB to Play with a higher versionCode; see Distribution.

Signed web-bundle OTA works the same way as on desktop. Configure only the web feed for Android builds:

updates: {
  feed: {
    web: "https://cdn.example.com/notes/",
  },
}

A single-string feed serves both tiers, so it counts as a whole-app feed. vidra build --target android fails when a whole-app feed is configured and tells you to switch to a web-only feed.

Google Play’s Device and Network Abuse policy exempts code that runs in an interpreter such as a WebView from its restriction on downloaded executable code. Web-bundle OTA fits that exemption; the rest of the Play policies still apply to what the bundle does.

Environment reference

Build-time:

VariableEffect
VIDRA_CHANNELDefault for --channel
VIDRA_UPDATE_SIGNING_KEYPrivate signing key PEM for CI; default for --sign
VIDRA_MACOS_KEYCHAINSelect a non-default signing keychain

Runtime, read by the installed app:

VariableEffect
VIDRA_UPDATE_FEED_URLOverride the web feed URL or local directory
VIDRA_UPDATE_CHANNELOverride the web update channel
VIDRA_ASSET_ROOTServe a directory directly and bypass updates
VIDRA_NATIVE_UPDATE_FEED_URLOverride the whole-app feed directory
VIDRA_NATIVE_UPDATE_CHANNELOverride the whole-app channel
VIDRA_NATIVE_UPDATE_ENABLEDSet to 0 to disable whole-app updates for one run

vidra dev never checks for updates; it serves the Vite development server.

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.