This document covers the first-release setup (one-time keypair generation) and the per-release process (build, sign, publish).
WaveFlow ships auto-updates via tauri-plugin-updater. Updates are
signed with a minisign keypair you control: the private key
signs each release, the public key is embedded in the app and
verifies signatures at install time. Without this you cannot ship
patches without users reinstalling manually.
bun run tauri signer generate -w ~/.tauri/waveflow.keyThis produces:
~/.tauri/waveflow.keyβ private key. Keep it secret. Back it up somewhere safe (1Password, hardware key, encrypted USB stick). Losing it means you can never ship another signed update for the current pubkey and existing users get stuck on their version.~/.tauri/waveflow.key.pubβ public key. Goes into the committed config.
You'll be prompted for a password. Use a strong one and store it next to the private key.
Open the public key file, copy its contents, and replace the
placeholder in src-tauri/crates/app/tauri.conf.json:
The pubkey is a single base64 line starting with RWQ or similar.
Commit this change.
The default endpoint is GitHub Releases:
https://github.com/InstaZDLL/WaveFlow/releases/latest/download/latest.json
If you self-host, change it to your manifest URL. The plugin
substitutes {{target}}, {{arch}}, and {{current_version}} in
the URL if you include those placeholders.
The CI workflow at .github/workflows/release.yml does the build,
sign, and upload steps automatically when a v* tag is pushed (or
when re-run manually via workflow_dispatch with an existing tag).
Set these once per repository (Settings β Secrets and variables β Actions):
| Secret | What it is |
|---|---|
TAURI_SIGNING_PRIVATE_KEY |
raw contents of ~/.tauri/waveflow.key |
TAURI_SIGNING_PRIVATE_KEY_PASSWORD |
passphrase for the above key |
SIGNTOOL_PFX_BASE64 |
base64 -w0 < cert.pfx for Windows Authenticode |
SIGNTOOL_PFX_PASSWORD |
PFX export passphrase |
AUR_SSH_PRIVATE_KEY |
private half of the SSH key registered on the maintainer's AUR account, used by .github/workflows/aur.yml to push PKGBUILD updates |
WINGET_PAT |
GitHub Personal Access Token (classic, public_repo scope) the .github/workflows/winget.yml action uses to fork microsoft/winget-pkgs and open the PR with the new manifest |
COPR_LOGIN |
login field from https://copr.fedorainfracloud.org/api/ β .github/workflows/copr.yml uses it to authenticate to Fedora COPR via copr-cli |
COPR_TOKEN |
token field from the same COPR API page (paired with COPR_LOGIN). Token lifetime is 6 months β rotate when builds start returning 401 Unauthorized |
BUILDKITE_PACKAGES_TOKEN |
Buildkite API token (read_packages + write_packages) for .github/workflows/apt-publish.yml to push the .deb to the instazdll/waveflow registry |
The macOS job signs the bundle either way. Without the secrets below it
falls back to an ad-hoc signature, which is enough to give the app a
sealed bundle and the stable app.waveflow identifier β see
macOS signing and the TCC prompt for
why that matters β but not enough for Gatekeeper. Setting them switches
the job to a real Developer ID signature plus notarization.
| Secret | What it is |
|---|---|
APPLE_CERTIFICATE |
base64 -i cert.p12 of the Developer ID Application certificate exported from Keychain Access (private key included) |
APPLE_CERTIFICATE_PASSWORD |
passphrase used at .p12 export time |
APPLE_SIGNING_IDENTITY |
full identity string, e.g. Developer ID Application: Jane Doe (A1B2C3D4E5) β read it from security find-identity -v -p codesigning |
APPLE_ID |
Apple ID email of the developer account |
APPLE_PASSWORD |
app-specific password generated at https://appleid.apple.com β not the account password |
APPLE_TEAM_ID |
10-character team identifier from https://developer.apple.com/account |
The first three are what enables signing; the last three add notarization on top. Providing only part of either group fails the build on purpose, rather than silently shipping a half-signed bundle β and so does the cross-group case, notarization credentials without a certificate, since there would be nothing signed to submit.
Setting none of the six stays a supported configuration: that is the ad-hoc build described above, chosen deliberately rather than reached by accident.
The AUR package itself (waveflow-bin) needs a one-off manual setup
on the maintainer's box β see packaging/aur/README.md.
The maintainer keeps local copies of all four key/cert files under
secrets/ (gitignored β see .gitignore) so they
can be re-uploaded when rotating. Push them to GitHub Actions secrets
with the gh CLI:
# Linux/macOS shell users: drop the "Get-Content -Raw" wrapper and pipe
# the file directly to gh secret set.
Get-Content -Raw secrets/aur | gh secret set AUR_SSH_PRIVATE_KEY
Get-Content -Raw secrets/waveflow.key | gh secret set TAURI_SIGNING_PRIVATE_KEY
[Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-Path secrets/cert.pfx))) | gh secret set SIGNTOOL_PFX_BASE64
gh secret set TAURI_SIGNING_PRIVATE_KEY_PASSWORD --body "<passphrase>"
gh secret set SIGNTOOL_PFX_PASSWORD --body "<passphrase>"You do not hand-bump versions anymore. Every push to main β or to
a maintenance branch, see
Patch releases from a maintenance branch β
runs .github/workflows/release-please.yml
against that branch, which:
- Parses the Conventional Commits since the last tag.
- Computes the next semver (
feat:β minor,fix:β patch,feat!:/BREAKING CHANGE:β major, no relevant commits β no PR). - Opens or refreshes a release PR β chore(main): release X.Y.Z on
main, chore(release/X.Y.x): release X.Y.Z+1 on a maintenance branch β that bumps every version manifest in lockstep:package.json(canonical, owned by release-please)src-tauri/crates/app/tauri.conf.json($.version)src-tauri/Cargo.toml($.package.version)README.mdversion badge (x-release-please-versionannotation)CHANGELOG.md(auto-generated entry)
- A second workflow
(
release-please-bump-lockfile.yml) runscargo checkon the PR branch and amendssrc-tauri/Cargo.lockso it stays consistent with the bumpedCargo.toml.
To ship, review and merge the release PR (squash or merge, release-please tolerates both). The merge causes release-please to:
- create the
vX.Y.Ztag on the branch it targets (main, or the maintenance branch), - create the matching GitHub Release with the auto-generated notes,
- update
.release-please-manifest.jsonto record the new version.
The tag is created with the workflow's GITHUB_TOKEN, and a tag pushed
that way does not trigger release.yml
(GitHub does not start workflows from events the token itself caused).
Start it by hand once the tag exists:
gh workflow run release.yml -f tag_name=vX.Y.ZThe workflow tags first and updates the release PR second: in the other order, the update right after a release PR merged went looking for a release that did not exist yet, walked up to 1,500 commits one API call at a time, and the job's timeout cut it before the tag was ever made.
.github/workflows/release.yml, started for the tag as above (a tag
pushed by hand would start it on its own; release-please's does not):
- Builds Linux on
ubuntu-24.04β produces an.AppImage(universal, also the updater payload), a.deb(Debian/Ubuntu), and an.rpm(Fedora/RHEL). The runner is pinned, neverubuntu-latest: a binary needs at least the glibc it was linked against, so the runner's glibc is the oldest one WaveFlow starts on. Whenubuntu-latestmoved to 26.04, the 1.9.6 binaries needed glibc 2.43 and stopped starting on Ubuntu 24.04, Debian 13 and Mint. A step after the AppImage is final fails the release if the binary, or any library the AppImage bundles from the runner, needs a glibc newer thanGLIBC_FLOOR(2.39); raise both together, on purpose. - Builds Windows on
windows-latest, signs every artefact with the Authenticode PFX β produces a*-setup.exe(NSIS, per-user under%LOCALAPPDATA%, also the updater payload) and a.msi(system-wide install for IT deployment). - Builds macOS (
*.dmg+*.app.tar.gz+.sig) onmacos-latestas a universal binary covering both Intel and Apple Silicon, and codesigns the bundle β with a Developer ID + notarization when theAPPLE_*secrets are set, ad-hoc otherwise. Ad-hoc still leaves Gatekeeper warning first-launch users (right-click β Open once). The minisign signature on the updater payload is produced independently, so auto-updates work either way. - Generates a per-platform
latest-<platform>.json - Creates the GitHub release if missing (with auto-generated notes from the commit log) and uploads every artefact
- A follow-up job merges the per-platform manifests into a single
latest.jsonand uploads that too - A separate workflow (
aur.yml) reacts to therelease.publishedevent, bumpspackaging/aur/PKGBUILD, refreshessha256sums/.SRCINFO, and pushes the result tossh://aur@aur.archlinux.org/waveflow-bin.gitso Arch users get the new version throughyay/paruautomatically
The Tauri updater plugin reads
https://github.com/<owner>/<repo>/releases/latest/download/latest.json
on app launch β that URL resolves to the merged manifest the workflow
just published.
If a build fails partway and you want to retry without re-tagging,
use the Run workflow button on the Release workflow page and
pass the existing tag (e.g. v0.2.0) as input.
On a machine running the previous version:
- Wait or restart the app β it checks
latest.jsonon launch. - The bottom-right banner should appear within seconds.
- Click "Install now", confirm the OS dialog if any, restart.
- Help β About should show the new version.
If the banner doesn't appear, check the console (F12 if devtools
are enabled in the build, or the platform's log directory) for
updater errors. Common causes: pubkey mismatch (private/public
keys regenerated), endpoint 404, malformed latest.json,
signature corrupted on upload.
Once main carries features for the next minor or major, a fix for the
version users have cannot be cut from it: release-please would bump to
the next minor and ship those features with it. A patch is cut from a
maintenance branch instead, release/X.Y.x, branched from the last tag
of that line:
- Create the branch from the tag:
git push origin vX.Y.Z:refs/heads/release/X.Y.x. - Bring the workflows to it. A push runs the pushed branch's copy of
release-please.yml, and a pull request runs its base's copy ofrelease-please-lockfile-build.yml; a tag older than theirrelease/**support has neither. Cherry-pick the commit that added it. - Cherry-pick each fix from
main(signed:git cherry-pick -S), through a pull request intorelease/X.Y.xso CI runs on it. The Maintenance branches ruleset protectsrelease/**the wayMainprotectsmainβ no deletion or force push, signed commits, a pull request with one approval β without the CodeQL gate, since CodeQL only analyses pull requests intomain. A push to these branches runs their own copy of the workflow with a write token, which is why they must not take direct pushes. - release-please, run on the branch with
--target-branch, openschore(release/X.Y.x): release X.Y.Z+1against it; the Cargo.lock pipeline fills it in as onmain. Merge it, then startrelease.ymlfor the new tag as above. Downstream (AUR, winget, COPR, apt) and the updater manifest work the same, since everything starts from the tag.
main keeps its own manifest, so its next release still counts from its
last stable version; its changelog will list the cherry-picked fixes a
second time, under the version that ships them on main. For a major,
set release-as in release-please-config.json
before merging the release PR.
WaveFlow ships an opt-in beta channel so testers can run
pre-release builds without affecting the stable population. Users
enable it under Settings β Maintenance β Updates β Beta channel; the choice
persists in app_setting['updater.channel'] (app-wide).
The in-app updater is driven from Rust
(commands/updater.rs)
rather than the JS check() so it can pick the endpoint matching the
active channel at runtime (the JS API only reads the static config
endpoints):
- stable β
releases/latest/download/latest.json. GitHub's/releases/latestalias excludes pre-releases, so a stable user is structurally incapable of seeing a beta β no flag, no opt-out needed. - beta β
releases/download/beta-channel/latest-beta.json. Thebeta-channelrelease is a fixed, rolling release whoselatest-beta.jsonassetrelease.ymlre-uploads (--clobber) on every pre-release tag. A pinned tag is required because GitHub has no "latest pre-release" URL alias.
Toggling off returns the user to stable on the next check: a
released 1.5.2 is semver-greater than their 1.5.2-beta.N, so the
stable endpoint serves it as an update.
Staying on the beta channel follows stable releases too (#793). Until 1.8.0 a tester read latest-beta.json alone, which only a pre-release tag ever rewrote, so someone on 1.8.0-beta.3 never saw 1.8.0. Two changes close that, one per side:
- the updater reads both manifests on the beta channel and keeps the newer answer (
updater.rs,is_newer, by semver). One manifest failing does not hide what the other found; release.ymlalso copies a stable manifest ontobeta-channelwhen that stable is newer than the beta published there. That is what reaches builds that predate the first change. A stable that is older β a1.8.1fix while1.9.0-beta.1is out β leaves the channel alone.
For 1.8.0 itself, the stable manifest was uploaded onto beta-channel by hand on 2026-10-02.
Run the Cut beta workflow
(cut-beta.yml) from the Actions
tab with a base_version input (e.g. 1.5.2). It computes the next
v1.5.2-beta.N tag, pushes it, and dispatches release.yml for it.
release-please is untouched β it still owns stable versions only; betas
live entirely outside its flow.
When the tag carries a semver pre-release suffix (-beta, -rc, β¦),
release.yml:
- marks the GitHub release
--prerelease; - publishes the merged manifest as
latest-beta.jsonon thebeta-channelrelease (instead oflatest.jsonon the version release); - skips the downstream AUR / Winget / COPR / apt dispatch β those are stable repositories only.
The per-platform installers still land on the v1.5.2-beta.N
pre-release; the beta manifest's payload URLs point there.
Tagging note: cutting a beta is the one sanctioned exception to the "never hand-tag" rule β but it's still automated (the workflow creates the tag, you don't). The rule's intent (don't bypass release-please for stable versions) is preserved.
Tauri's AppImage bundler copies the webview's dependency closure into the AppDir, and that closure contains libwayland-client.so.0 β a library the official AppImage excludelist explicitly forbids bundling, because Mesa breaks against a bundled copy.
We build on Ubuntu. On any host with a newer Mesa β Fedora 44 ships Mesa 26 and libwayland 1.25 β the bundled copy wins the lookup and WebKit's WebProcess dies before painting anything:
Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...
The Rust side is unaffected: the app starts, creates its profile, opens its audio device, and shows an empty window. That asymmetry is why this was misread for months as a WebKitGTK 2.52 incompatibility, with users told to install a native package instead. It is not a WebKit problem, and it is not unfixable β scripts/fix-appimage.sh removes the file (and libva, see the GStreamer section), repacks the squashfs with the compressor the bundler used, verifies the result re-extracts, and re-signs when an updater .sig is present.
Both release.yml and test-appimage.yml run it right after tauri build. If you ever restructure the Linux build, that step has to survive β dropping it silently produces a release that installs fine and never opens, on exactly the distributions least likely to be in your test matrix. The script no-ops (and says so) if a future Tauri stops bundling the library, so it's safe to leave in place.
WebKitGTK plays every <video> in the interface β Canvas clips, animated album covers β through GStreamer. The AppImage bundles libgstreamer-1.0.so.0 as part of the webview's dependency closure, and that library is relocatable: it looks for its plugins next to itself, in usr/lib/gstreamer-1.0/ inside the AppDir, not in the host's plugin directory. Until 1.8.0 that directory did not exist, so the bundled GStreamer found no plugins at all, even on a host that had them installed:
GStreamer element appsink not found. Please install it.
GStreamer element autoaudiosink not found. Please install it
(WebKitWebProcess): GLib-GObject-CRITICAL: g_signal_connect_data: assertion 'G_TYPE_CHECK_INSTANCE (instance)' failed
The web process then waits on a pipeline that was never built, and the whole interface freezes the first time a video is shown. Because the player resumes the last track on launch, a track with an animated cover froze the app again at every start. The released 1.7.0 AppImage had the same defect (it bundles Ubuntu's relocatable GStreamer 1.24). The native packages, the AUR, COPR and Flatpak builds were never affected: they use the system GStreamer.
bundle.linux.appimage.bundleMediaFramework in tauri.conf.json fixes it: the bundler copies the build machine's GStreamer plugins into the AppDir and points the runtime at them. It bundles what the runner has installed, so release.yml and test-appimage.yml install the plugin sets the webview needs:
| Package | Why |
|---|---|
gstreamer1.0-plugins-base |
appsink, playbin, decodebin, the converters WebKit builds on |
gstreamer1.0-plugins-good |
qtdemux (the .mp4 container), autoaudiosink |
gstreamer1.0-plugins-bad |
the va hardware decoders (VA-API), used where the host driver decodes H.264 / HEVC |
gstreamer1.0-libav |
the H.264 and HEVC decoders the clips and covers are encoded with |
Two more pieces make video actually play, and neither is packaging. The bundled GStreamer would otherwise share the host's registry cache (~/.cache/gstreamer-1.0/registry.<arch>.bin) with a different GStreamer version, so preflight_appimage_gstreamer in lib.rs points GST_REGISTRY_1_0 at a file of its own when APPIMAGE is set. The same function points the plugin path at a fixed symlink, ~/.cache/app.waveflow/gstreamer-plugins, re-aimed at the current mount on every launch (and left alone while it still leads into the mount of another instance that is running): the AppImage mounts under a new /tmp/.mount_* each time, and a registry keyed by those paths went stale at every launch, so GStreamer rescanned all its plugins the first time a video was shown and froze the interface for 3 to 10 seconds. Through the link, only an update of the AppImage costs a rescan. And WebKitGTK cannot play the asset protocol at all, on any Linux package: local clips go through a loopback HTTP server β see ui.md.
libva is not bundled. fix-appimage.sh removes it after the build, like libwayland-client: it loads the host's VA driver at run time, and the runner's older copy failed against Fedora's iHD driver (vaInitialize: unknown libva error, then msdkcontext: Couldn't create a VA DRM display). WebKit itself links libdrm, not libva, so the app starts either way; a host without libva only loses the decoders that need it. Whether a video is then decoded in hardware is the host driver's call: Fedora's own libva-intel-media-driver has no H.264 or HEVC (only vavp9dec registers), so on that machine a 1080p cover decodes in software, about half a core of an i7-1165G7, as it would in a native package.
Leave gstreamer1.0-plugins-ugly out: Tauri's own guidance warns that its licences make it hard to redistribute. Tauri also documents the flag as fully supported only on Ubuntu build systems, which is what the release runs on β a local AppImage built on another distribution is not a valid test of it. Use test-appimage.yml.
The AppImage's launcher points LD_LIBRARY_PATH, GIO_MODULE_DIR, GTK_PATH, GSETTINGS_SCHEMA_DIR, PATH, XDG_DATA_DIRS and a few more into the mount, so that the bundled WebKitGTK finds its own libraries. Every program the app starts inherits them. The opener plugin's xdg-open then ran the host's gio, file manager and browser against the image's Ubuntu libraries, and they died on the first missing symbol. On Fedora, gio stopped at undefined symbol: g_unix_mount_entry_get_options and Firefox at version NSS_3.126 not found, so "Open log folder" did nothing. The native packages never set those variables.
external_open is the one way the app opens a folder or a link. When APPIMAGE is set, it starts xdg-open with every entry under $APPDIR removed from the environment, and falls back to gio open when xdg-open cannot start or exits reporting no tool (3) or a failed open (4). It also withholds the variables the app set for the image itself: the GStreamer registry and plugin paths from the section above. They reach the mount through the cache-directory link, so nothing in their value names it. In any package, it withholds GDK_BACKEND the same way when the app set it to run through XWayland (#894), so a browser it starts is not pushed onto XWayland too; outside an AppImage, that is the only case it starts the launchers itself. Otherwise it hands over to the plugin unchanged. The frontend's links go through its open_external_url command, which accepts http, https and mailto only, because plugins pass their own links there. Revealing a track in its folder stays on the plugin: it asks the file manager over D-Bus, which starts it with its own environment.
The AppImage runtime reserves a 1024-byte .upd_info section for a single string saying where updates come from. appimagetool -u fills it; Tauri builds the AppImage itself and exposes no equivalent, so it shipped zeroed and every ecosystem tool β AppImageUpdate, AppImageLauncher, AM, AppManager β reported no update information available (#527).
scripts/appimage-update-info.sh writes it:
gh-releases-zsync|InstaZDLL|WaveFlow|latest|WaveFlow_*_linux-x86_64.AppImage.zsync
latest is a literal the transport resolves through the GitHub API on every check, so the string stays correct for all future versions. Paired with the .zsync control file zsyncmake emits, a client reuses what it already has and fetches only the changed blocks β measured on a synthetic release: 3 080 192 bytes reused locally, 65 774 fetched, reconstructed file identical to the published one.
The section is written with dd conv=notrunc at the offset readelf reports, never objcopy --update-section. This file is an ELF with a squashfs appended that the runtime locates by a fixed offset; rebuilding the container would move that boundary and produce an image that no longer mounts. The script asserts the file size and the --appimage-offset are unchanged afterwards, so that mistake cannot ship quietly.
The order is load-bearing, and all three steps mutate or describe the same bytes:
fix-appimage.shβ repacks the squashfs (keeps the runtime header verbatim, so anything written into it survives)appimage-update-info.shβ writes.upd_info, then re-signs, because the.sigcovers the bytes it just changedzsyncmakeβ runs last, on the renameddist-release/copy
Step 3's placement matters twice. Its checksums describe the payload byte for byte, so any later rewrite would silently invalidate it. And the control file names its target, which clients resolve relative to the URL they fetched the .zsync from β both are assets of the same release, so the bare renamed filename lands on the right image, while the bundler's own name would send every client to a 404.
If you rename the Linux release assets, the embedded pattern has to move with them. WaveFlow_*_linux-x86_64.AppImage.zsync lives in the script, the asset name lives in release.yml, and they are two halves of one contract: rename one and clients ask for a .zsync that was never published β visible to users, invisible in CI. Step 3 therefore reads the string back out of the image it is about to ship and fails the release when the two disagree. That check fires on a tag, which is a poor moment to discover it, so treat it as a backstop and change both together.
Stable only. The update information points at the repository's latest release, and GitHub excludes pre-releases from "latest" β a beta carrying that string would walk its user back to the newest stable. Beta builds keep an empty section, so those clients honestly report no update information and the in-app beta channel stays the only path.
Flathub builds with the network disabled, so every crate and npm tarball must be pre-declared in packaging/flatpak/generated/ by generate-sources.sh.
A Cargo.lock / package.json bump that skips that regeneration does not fail CI β it fails much later, inside Flathub's sandbox, on a crate nobody can download. chore: update dependencies (23be643) left 53 crates undeclared exactly this way.
check-sources.py guards the invariant on every PR touching a lockfile. It verifies coverage β every registry crate in Cargo.lock has a source entry, every npm package in the Flatpak lockfile has a tarball β offline, in about a second.
It deliberately does not regenerate-and-diff: the generator runs npm install --package-lock-only, which re-resolves ^ ranges against the live registry, so such a check would go red whenever any transitive dependency publishes. Freshness is instead a monthly chore (flatpak-sources-refresh.yml, also workflow_dispatch-able) that regenerates and opens a PR.
Toolchain split. The app builds from
bun.lock, but the Flatpak sandbox runsnpm ci --offlineagainst a separate npm lockfile (flatpak-node-generatorcan't readbun.lock). A dependency pin that must hold for shipped builds therefore belongs inpackage.jsonβ e.g. anoverridesentry β not inbun.lockalone.
The manifest's own paths go stale the same way. No CI job runs
flatpak-builder, andcheck-sources.pyonly reads the lockfiles, so nothing checks the repository paths the manifest'sbuild-commandsinstall from β the binary, the icons, the desktop entry. The icon step kept readingsrc-tauri/icons/long after the workspace split moved them undersrc-tauri/crates/app/icons/. Moving or renaming anything undersrc-tauri/crates/app/means grepping the manifest for it.
Signing the macOS bundle is not only about Gatekeeper. It is what stops macOS from asking for folder permission on every single launch.
macOS records a TCC grant β the "WaveFlow would like to access files in your Downloads folder" consent β against the app's designated requirement, i.e. its code-signing identity. Before this was wired up, the shipped bundle carried only the ad-hoc signature the linker adds automatically on Apple Silicon:
Identifier=waveflow-36c95c4d36c8f6a6 β generated, not app.waveflow
Signature=adhoc
flags=0x20002(adhoc,linker-signed)
Info.plist=not bound
Sealed Resources=none
There is no Contents/_CodeSignature in that state: the .app was never passed to codesign, only its Mach-O binary was. With no sealed bundle and no stable identifier, there is nothing to anchor the grant to, so it never persists. Any user whose library lives under ~/Downloads, ~/Desktop, ~/Documents, an external drive or a network share re-consents at every launch.
Signing the bundle fixes it, and ad-hoc is enough for this specific problem β codesign -s - still produces sealed resources, a bound Info.plist and the app.waveflow identifier from the bundle. What ad-hoc does not do is survive an update (each build is a different identity, so the grant resets on upgrade) or satisfy Gatekeeper. A Developer ID identity is stable across versions and does both.
Three pieces make this work, and they have to stay together:
Info.plistβ theNS*UsageDescriptionpurpose strings macOS shows in the consent dialog. Auto-merged by the bundler because it sits next totauri.conf.json.waveflow.entitlementsβ signing turns on hardened runtime, which the old linker-signed bundle never had, so signing introduces a failure mode rather than only removing one. The wasmtime plugin host executes Cranelift output from anonymousmmap+mprotectpages, notMAP_JIT, so it needscom.apple.security.cs.allow-unsigned-executable-memory;com.apple.security.cs.allow-jitalone is not enough and the process is SIGKILL'd (Namespace CODESIGNING, Code 2, Invalid Page) the moment a plugin instantiates. Web Radio ships in the bundle, so that path runs for every user. Verified against the pinned source, not assumed: wasmtime 47.0.3 contains noMAP_JITat all andMmap::make_executablegoes through plainmprotect, so no narrower grant exists β re-evaluate only if the plugin host changes how it maps executable memory. The companionallow-jitis not redundant: JavaScriptCore runs in-process for the WKWebView and JITs viaMAP_JIT. The two entitlements serve two different consumers, so neither can be pruned as dead weight.- The
Resolve macOS signing mode+Verify macOS signaturesteps inrelease.yml.
Signing happens inside tauri build, driven by APPLE_SIGNING_IDENTITY, not as a post-build codesign pass. That ordering is not cosmetic: the bundler builds the DMG and the .app.tar.gz updater payload from the .app, so signing afterwards would leave both wrapping an unsigned copy.
The verify step asserts the sealed-resources / app.waveflow / designated-requirement triple, because a silent signing failure would look like a clean release and quietly bring the per-launch prompt back.
- Dev builds skip the updater entirely (gated on
cfg(not(debug_assertions))inlib.rs). You will not see update prompts duringbun run tauri dev. - Windows install mode is
passiveβ the user sees a brief installer GUI, no clicks needed. Switch toquietfor fully silent (less obvious to the user) orbasicUifor the standard NSIS dialog. - Windows Authenticode signing is wired through the release
workflow via
SIGNTOOL_PFX_BASE64+SIGNTOOL_PFX_PASSWORDsecrets. The certificate in those secrets is self-signed, so the builds are signed but not trusted: the chain doesn't end at a CA in Microsoft's Trusted Root Program, and Windows treats WaveFlow as coming from an unknown publisher. Downloads won't change that. Smart App Control blocks the app whenever Microsoft's online reputation service has no positive verdict for the build, which is why it fails on some days and works on others (#610). Microsoft's own comparison lists a self-signed certificate as blocking installation for public users. - What fixes it is a code-signing certificate from a CA in the
Trusted Root Program: with that, Smart App Control allows the app
even without any reputation. EV is no shortcut: SmartScreen has
treated EV and OV the same since 2024. Every executable file that
ships must carry the signature, not only the installer: the app
binary, DLLs and the uninstaller too. To check a release,
Get-AuthenticodeSignatureon the installer and on the installedwaveflow.exemust reportValid; the self-signed certificate reportsUnknownError(untrusted root).