Updates
Vidra apps update in two tiers. Use either, both, or neither.
| Ships | Mechanism | Needs | |
|---|---|---|---|
Web (vidra build --web) | your ui/ build | a new bundle, applied on the next launch | a feed URL |
Whole-app (vidra build --app) | the whole app, native code included | Velopack replaces the install in place | a 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:
| Config | Feed directory |
|---|---|
one feed URL, or both tiers pointing at the same URL | dist/feed/ |
| different web and app URLs | dist/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:
- 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.
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:
| Question | Answered 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:
| 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/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:
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. - 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:
| Variable | Effect |
|---|---|
VIDRA_CHANNEL | Default for --channel |
VIDRA_UPDATE_SIGNING_KEY | Private signing key PEM for CI; default for --sign |
VIDRA_MACOS_KEYCHAIN | Select a non-default signing keychain |
Runtime, read by the installed app:
| 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_NATIVE_UPDATE_FEED_URL | Override the whole-app feed directory |
VIDRA_NATIVE_UPDATE_CHANNEL | Override the whole-app channel |
VIDRA_NATIVE_UPDATE_ENABLED | Set 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.