Desktop packaging & CI - runbook
How the desktop app (the "Quiet Console") is built, shipped, and updated - and the non-obvious things that bit us, so the next person/agent doesn't relearn them. Pairs with release-versioning.md.
Architecture
The desktop app is two pieces that ship as one package:
ui-shell/- a Tauri 2 shell (Rust) that draws the frameless window + tray and hosts the web UI built fromui/(React/Vite). Seeui-shell/README.md.- The Python engine - the real product (STT, audio, push-to-talk, typing). It
is PyInstaller-frozen (
packaging/) into adictate-enginefolder (the launcher plus_internal/; a single self-extracting binary only for the AppImage) and embedded in the bundle as a Tauri resource (bundle.resources).
Linux host audio (do not freeze PortAudio)
On Linux the engine must link against distro PortAudio, not a copy frozen
into _internal/:
.deb/.rpmdepend onlibportaudio2+libpulse0(Pulse/PipeWire client).packaging/dictate-engine.specstripslibportaudio*,libasound*, andlibpulse*viapackaging/host_audio_libs.py.packaging/build-engine.shfails the freeze if those libs still appear in the output (override only withDICTATE_BUNDLE_HOST_AUDIO=1for experiments).- App-level defense:
resolve_input_capture()prefers the Pulse host API /sysdefaultand resamples to 16 kHz when needed.
Bundling an ALSA-only PortAudio made the engine default to raw hw:* devices
that reject 16 kHz under PipeWire (observed on Ubuntu 26). Windows continues to
ship PortAudio DLLs inside the freeze.
AppImage builds also expect host libportaudio2; prefer the .deb on Ubuntu.
On launch the shell spawns the engine once as a headless process that both
dictates (--no-tray) and serves the control API (DICTATE_UI_SERVER=1,
implemented in src/dictate/ui_server.py). The webview talks to that server over
loopback HTTP with a bearer token written to ~/.local/share/dictate/ui-server.json.
The desktop bundles stage the default offline resources beside the frozen
engine: Parakeet v2 int8 ONNX for English dictation. Customers do not need
Hugging Face accounts or model downloads for it, and the build needs no
Hugging Face token: Parakeet v2 int8 is public. The pyannote Community-1 model
the builds used to stage for Meeting, and the token it needed, went with Meeting
(#140). packaging/dictate-engine.spec and packaging/build-engine.sh fail
the build if torch or pyannote is frozen into the engine again.
The bundled engine runs Parakeet on the CPU through the onnxruntime package.
There are no GPU builds: the gpu and amd extras were removed when Dictate
went CPU-only on 2026-10-01.
Build / release flow
scripts/build-linux-desktop.sh
├─ npm --prefix ui run build # web UI -> ui/dist
├─ packaging/build-engine.sh # onedir for deb/rpm; onefile for AppImage
├─ stage engine -> ui-shell/src-tauri/engine/
└─ tauri build --bundles deb
- CI (
.github/workflows/ci.yml, jobdesktop-shell) compiles the shell and runs its Rust tests on every push (with a placeholder engine — no freeze). - Release (
.github/workflows/release.yml, joblinux-desktop) runs the full build after the manually dispatched release workflow verifies the requestedv20*tag is reachable from the default branch, then attaches the.debto the GitHub release. RPM packaging is intentionally not part of the release lane because it has timed out in CI after producing the.deb; reintroduce it only after the RPM bundling path is fixed and timed. - Manual (
.github/workflows/desktop-bundle.yml,workflow_dispatch) builds the bundle and uploads artifacts + the full log — use this to iterate on packaging without cutting releases. Trigger:gh workflow run desktop-bundle.yml.
Windows build / release flow
The Windows path mirrors the Linux .deb/.rpm architecture: it stages the
onedir engine folder and builds Tauri's Windows bundle targets:
scripts/build-windows-desktop.ps1
├─ npm --prefix ui run build
├─ create packaging\.build-venv-windows
├─ pip install -e ".[windows]" pyinstaller
├─ pyinstaller packaging\dictate-engine.spec (onedir; DICTATE_ONEFILE unset)
├─ stage packaging\dist\dictate-engine\* -> ui-shell\src-tauri\engine\
│ (dictate-engine.exe + _internal\, then models and notices)
├─ fail if any engine path is over 150 chars
└─ tauri build --bundles msi,nsis
-
The engine is installed as a folder (
engine\dictate-engine.exebesideengine\_internal\), not a onefile exe. A onefile exe unpacks its whole runtime into%TEMP%on every launch; the 333 MB Store build spent ~7 s on that and missed the shell's 8 s handshake wait (#131). The Windows spec also drops build-only files (headers,.lib, CMake files,.pdb) from the folder. -
scripts/windows-engine-smoke.ps1starts the staged engine the way the shell does (--no-tray,DICTATE_UI_SERVER=1, throwawayLOCALAPPDATA/APPDATA) and writes the handshake time, first authenticated/api/statetime, file count and size to the job summary. The manual Windows bundle and Store MSIX workflows run it after the build. -
The engine the shell starts writes the handshake before it loads the Parakeet model, and loads the model on a background thread (#155), so the window connects while the model loads.
/api/statereportsmodelReadyandmodelLoad({phase: loading | ready | failed, error}), and the event stream sends amodelevent when that changes. The model load holds the Python GIL for its whole length (the engine answers no request meanwhile, measured on Windows), so recording and the shortcut listener start once the model is ready; until then the window says "Getting ready…". A failed load is shown in the window and the engine stays up. -
Release (
.github/workflows/release.yml, jobwindows-desktop) runs the full Windows build after the manually dispatched release workflow verifies the requestedv20*tag is reachable from the default branch. Public stable Windows distribution supports Microsoft Store stable builds and direct-download stable/unstable builds. Direct installers are unsigned unless we later decide to pay for and maintain Authenticode signing. -
Unstable (
.github/workflows/npm-unstable.yml) runs the same Windows desktop build and the Linux user encrypted-sync install smoke before moving the npmunstabledist-tag. It uploads the.msiand NSIS setup.exeas workflow artifacts and durable GitHub prerelease assets for the exact unstable commit. -
Manual (
.github/workflows/windows-desktop-bundle.yml,workflow_dispatch) builds the Windows bundle and uploads artifacts + the full log without cutting a release tag. Trigger:gh workflow run windows-desktop-bundle.yml. -
Manual (
.github/workflows/windows-msi-release.yml,workflow_dispatch) is retained as future plumbing for a paid Authenticode direct-download lane. It is not part of the current Windows release plan and should not block tester-ready or Store-ready work. -
Unsigned Windows MSI/NSIS artifacts are acceptable for early/staging testers. Those users will see Windows untrusted-publisher warnings and may need to approve the install manually. That is expected for staging builds. The stable public Windows path remains the Microsoft Store package tracked in msstore-automation.md.
-
The Tauri shell looks for
dictate-engine.exeon Windows and fordictate-engineelsewhere. It also reads the UI handshake from%LOCALAPPDATA%\dictate, matchingsrc/dictate/platform_paths.py. -
scripts/assert-windows-artifacts-signed.ps1andscripts/sign-windows-artifacts.ps1exist only for the future paid Authenticode lane. Do not keep revisiting Windows signing configuration during ordinary release or staging work. We are not currently paying for that certificate path.
If a future revenue-backed signing lane is explicitly approved, backfill a signed MSI onto an existing release after the signing certificate is configured:
gh workflow run windows-msi-release.yml \
-f release_tag=v2026.7.4 \
-f overwrite=false
Use -f overwrite=true only when replacing a bad or superseded MSI asset on the
same immutable release tag.
Windows Store MSIX flow
The reserved Partner Center product is Arc Forge Dictate
(9P5S7747V0BP) and its type is MSIX or PWA app. Tauri's built-in Windows
bundler still emits MSI/NSIS installers, so Store MSIX packaging uses
Microsoft's winapp CLI and a repo-owned manifest:
scripts/build-windows-msix-store.ps1
├─ run scripts\build-windows-desktop.ps1 -Bundles no-bundle
├─ read shared target\release\dictate-ui-shell.exe
├─ read shared target\release\engine\dictate-engine.exe
├─ stage shell + engine folder into the MSIX loose layout
├─ fail unless engine\_internal\ is present and every path is <= 150 chars
├─ render packaging\msix\Package.appxmanifest.in
├─ winapp tool makeappx pack, or Windows SDK makeappx.exe
└─ unpack and validate manifest identity + shell/engine payloads
- Manual (
.github/workflows/windows-msix-store-bundle.yml,workflow_dispatch) buildspackaging/msix/out/*.msixfor Partner Center package validation. Trigger:gh workflow run windows-msix-store-bundle.yml. - Store MSIX packaging consumes the same no-bundle Windows desktop payload that is used to produce the direct installer artifacts. The Store wrapper changes package format, identity, and manifest metadata; it does not rebuild a different shell/engine application stack.
scripts/build-windows-msix-store.ps1prefers Microsoft'swinappCLI, but can fall back to Windows SDKmakeappx.exewhenwinappis not installed. After packing, it unpacks the MSIX and validates the Partner Center identity, package version, shell executable, and engine sidecar payload.- Manual (
.github/workflows/msstore-publish-msix.yml,workflow_dispatch) uses Microsoft Store Developer CLI for current-state checks, draft package upload, or explicit publish/commit. Usemode=statusfor read-only checks,mode=draftto upload a generated MSIX without committing, andmode=publishonly after the draft should be submitted to Microsoft. - Store publication is not triggered by GitHub release publication. After a
release is tagged and verified, create the Store draft with
mode=draft, review it in Partner Center, then usemode=publishwhen it is ready for Microsoft certification. - Local VM evidence:
scripts/windows-vm-smoke.sh --vm win11-dev --mode msix --timeout 2400 --keep-guest-workdirpassed on 2026-07-05 after the current Parakeet-first installer/default, Meeting readiness, benchmark, evidence-import, and segment-aware UI changes. It producedC:\Users\Public\dictate-vm-smoke\source\packaging\msix\out\ArcForgeDictate_2026.7.4.0_x64.msix, smoke-tested the frozen engine binary, and validated the unpacked manifest, shell executable, and engine sidecar. - Current install smoke evidence:
scripts/windows-vm-smoke.sh --vm win11-dev --mode install --timeout 1800 --keep-guest-workdirpassed on 2026-07-05 after resetting the guest Dictate app-data directory, seeding a fresh config, compiling Python sources, running 314 focused Windows tests with 16 skips, verifyingdictate 2026.7.4, and confirming defaultdictate doctor --quick --type-backend pynputreports Parakeet v2. - Current lifecycle smoke evidence:
scripts/windows-vm-smoke.sh --vm win11-dev --mode lifecycle --timeout 2400 --keep-guest-workdirpassed on 2026-07-05 after the same fresh-config install path, update, default Parakeet doctor check, and uninstall. - The Windows AMD DirectML smoke (
--mode amd) was removed with the GPU lanes on 2026-10-01 (#112); its last evidence is in this file's git history. - The manifest identity is pinned to Partner Center:
ArcForgeLabs.ArcForgeDictateandCN=56989B1A-E9FD-45E0-827B-FDB65D3C9B3C. - The MSIX package stages the frozen engine next to the shell executable under
engine\; the Tauri shell checks both Tauri's resource directory and the executable directory for that sidecar. - Microsoft Store distribution should sign the final accepted package. Any local MSIX signing certificate is for internal install testing only.
- If this MSIX path fails Partner Center package validation, the fallback is to create a separate Partner Center EXE or MSI app product and submit the signed Tauri installer through Microsoft's MSI/EXE Store flow.
Gotchas (the expensive lessons)
Match the engine layout to the bundle format
A PyInstaller onedir engine ships its libraries in _internal/, which native
.deb and RPM packages and every Windows package (MSI, NSIS, Store MSIX) carry
as an installed folder, with no unpacking at launch. Use onedir for those.
AppImage's linuxdeploy walks the internal libraries and is less reliable with
that layout, so AppImage builds alone set DICTATE_ONEFILE=1 and stage one
self-extracting ELF instead.
Tauri icons must be RGBA PNG
tauri::generate_context! panics at compile time with icon ... is not RGBA if
any configured icon isn't RGBA. scripts/render_brand_icons.py renders every icon
(assets/*.png|ico and ui-shell/src-tauri/icons/*) from the SVGs as RGBA — never
hand-export one.
Keep optional Linux formats out of the release gate until timed
The public release workflow currently publishes the .deb only. RPM/AppImage
experiments belong in manual bundle workflows until they are reliable and timed;
an optional package format must not sink the primary GitHub release.
Release pipeline must be resilient
- npm publish is its own job. It runs beside the GitHub release job, so an
npm problem cannot skip the GitHub release or installers. A failed publish now
fails the release run instead of warning, so
latestcannot silently fall behind (it did from July to September 2026). Publishing uses trusted publishing; see release-versioning.md. gh release createis idempotent —upload --clobberif the release exists, so re-running after a bundle fix doesn't fail on a duplicate.
Windows CI is part of the matrix — avoid POSIX-isms
strftime("%-I"/"%-M"): the%-pad flag is glibc-only and raisesValueError: Invalid format stringon Windows. Build clock strings manually.str(Path("/a/b"))is\a\bon Windows; test assertions must compare againststr(Path(...)), not hard-coded forward-slash strings.- Tests run with stdlib unittest (
python -m unittest discover -s tests), not pytest.
Version bump is scripted
Use python scripts/sync_release_version.py X to update package metadata,
hosted installer pins, Tauri versions, and the MSI-safe WiX version from one
CalVer value YYYY.M.N, where N is the release count in the month, not the
day. Leave out X to use the next version; the script refuses an X that is
not the next release this month. Do not touch the
parse/compare fixtures in tests/test_update_status.py (they use old versions as
generic logic examples). python scripts/sync_release_version.py X --check and
python scripts/release_check.py --tag vX must both pass.
You can't build the Tauri bundle in the dev sandbox
The design/dev sandbox has Node + Python (so ui/, the freeze, and the Python
suites are fully buildable/testable) but no Rust/cargo, no webkit2gtk-4.1-dev,
static.crates.io is firewalled, and sudo needs a password. So: verify the
PyInstaller freeze locally (it needs none of those), but iterate the Tauri build
on CI via desktop-bundle.yml. Don't burn time trying to cargo build locally.
Install, update, conflicts
- Default Linux release install is per-user.
./install.shinstalls into~/.local/share/dictate, links commands into~/.local/bin, and creates a per-user desktop launcher/autostart entry../update.shstays on this channel by default and does not require sudo. - System Linux package install is explicit.
./install.sh --systemor./update.sh --systeminstalls a local.debinto/usrthroughpkexecorsudo. Use this channel for managed machine-wide installs, not as the default developer/user update path. - One package at a time. A stable
.deband a beta.debare different builds. Installing one replaces the other. About shows which build is installed. It does not switch channels in place. - In-app
.debupdates stay open until the user installs. A check downloads and verifies the new package in the background. The button then sits on Update ready. The next click installs that package withapt-getand restarts the whole app, window and engine together. Dictate does not close itself when the download finishes. - The user install and the package both ship a daemon and would fight over the
push-to-talk key.
install.shwarns when a package is installed;uninstall.shstops a running source/user daemon and removes thedictate-ui-serversymlink + logs, preserving~/.config/dictateand history by default (--remove-user-datato wipe). - Same-version reinstall (e.g. swapping an onedir
.debfor a onefile one with the same version string) needssudo apt install --reinstall ./<file>.deb— apt skips an equal version otherwise. Download the asset first;apt install ./bare-name.debfails with "Unsupported file" if the path doesn't exist.
Open / known follow-ups
- Autostart: on its first run the packaged engine self-registers both the
app-menu launcher and a per-user login autostart entry
(
~/.config/autostart/dictate.desktop,Exec=dictate-ui-shell), gated by a~/.local/share/dictate/.desktop-integratedmarker so it runs once and never overrides a user who later disables startup (dictate config set-startup off).dictate doctorflags a launcher/startup entry whoseExectarget is missing (e.g. a stale pip-era~/.local/bin/dictateafter migrating to the package), anddictate doctor --fixrepairs it.