# 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](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 from **`ui/`** (React/Vite). See `ui-shell/README.md`.
- **The Python engine** - the real product (STT, audio, push-to-talk, typing). It
  is **PyInstaller-frozen** (`packaging/`) into a `dictate-engine` folder (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` / `.rpm` depend on `libportaudio2` + `libpulse0` (Pulse/PipeWire client).
- `packaging/dictate-engine.spec` strips `libportaudio*`, `libasound*`, and
  `libpulse*` via `packaging/host_audio_libs.py`.
- `packaging/build-engine.sh` fails the freeze if those libs still appear in the
  output (override only with `DICTATE_BUNDLE_HOST_AUDIO=1` for experiments).
- App-level defense: `resolve_input_capture()` prefers the Pulse host API /
  `sysdefault` and 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`, job `desktop-shell`)** compiles the shell and
  runs its Rust tests on every push (with a placeholder engine — no freeze).
- **Release (`.github/workflows/release.yml`, job `linux-desktop`)** runs the full
  build after the manually dispatched release workflow verifies the requested
  `v20*` tag is reachable from the default branch, then attaches the `.deb` to
  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.exe` beside
  `engine\_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.ps1` starts the staged engine the way the shell
  does (`--no-tray`, `DICTATE_UI_SERVER=1`, throwaway `LOCALAPPDATA`/`APPDATA`)
  and writes the handshake time, first authenticated `/api/state` time, 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/state` reports `modelReady` and
  `modelLoad` (`{phase: loading | ready | failed, error}`), and the event stream
  sends a `model` event 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`, job `windows-desktop`)** runs the
  full Windows build after the manually dispatched release workflow verifies the
  requested `v20*` 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 npm `unstable` dist-tag. It uploads the `.msi` and NSIS setup `.exe` as
  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](msstore-automation.md).
- The Tauri shell looks for `dictate-engine.exe` on Windows and for
  `dictate-engine` elsewhere. It also reads the UI handshake from
  `%LOCALAPPDATA%\dictate`, matching `src/dictate/platform_paths.py`.
- `scripts/assert-windows-artifacts-signed.ps1` and
  `scripts/sign-windows-artifacts.ps1` exist 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:

```bash
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`)** builds `packaging/msix/out/*.msix` for 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.ps1` prefers Microsoft's `winapp` CLI, but
  can fall back to Windows SDK `makeappx.exe` when `winapp` is 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. Use `mode=status` for read-only checks,
  `mode=draft` to upload a generated MSIX without committing, and `mode=publish`
  only 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 use `mode=publish` when it is ready for
  Microsoft certification.
- Local VM evidence: `scripts/windows-vm-smoke.sh --vm win11-dev --mode msix
  --timeout 2400 --keep-guest-workdir` passed on 2026-07-05 after the current
  Parakeet-first installer/default, Meeting readiness, benchmark,
  evidence-import, and segment-aware UI changes. It produced
  `C:\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-workdir` passed 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,
  verifying
  `dictate 2026.7.4`, and confirming default `dictate doctor --quick
  --type-backend pynput` reports Parakeet v2.
- Current lifecycle smoke evidence: `scripts/windows-vm-smoke.sh --vm
  win11-dev --mode lifecycle --timeout 2400 --keep-guest-workdir` passed 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.ArcForgeDictate` and
  `CN=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 `latest` cannot silently fall
  behind (it did from July to September 2026). Publishing uses trusted
  publishing; see release-versioning.md.
- **`gh release create` is idempotent** — `upload --clobber` if 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 raises
  `ValueError: Invalid format string` on Windows. Build clock strings manually.
- `str(Path("/a/b"))` is `\a\b` on Windows; test assertions must compare against
  `str(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.sh` installs into
  `~/.local/share/dictate`, links commands into `~/.local/bin`, and creates a
  per-user desktop launcher/autostart entry. `./update.sh` stays on this channel
  by default and does not require sudo.
- **System Linux package install is explicit**. `./install.sh --system` or
  `./update.sh --system` installs a local `.deb` into `/usr` through `pkexec` or
  `sudo`. Use this channel for managed machine-wide installs, not as the default
  developer/user update path.
- **One package at a time.** A stable `.deb` and a beta `.deb` are different
  builds. Installing one replaces the other. About shows which build is
  installed. It does not switch channels in place.
- **In-app `.deb` updates 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 with `apt-get` and
  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.sh` warns when a package is installed;
  `uninstall.sh` stops a running source/user daemon and removes the
  `dictate-ui-server` symlink + logs, **preserving `~/.config/dictate` and
  history by default** (`--remove-user-data` to wipe).
- Same-version reinstall (e.g. swapping an onedir `.deb` for a onefile one with the
  same version string) needs `sudo apt install --reinstall ./<file>.deb` — apt
  skips an equal version otherwise. Download the asset first; `apt install
  ./bare-name.deb` fails 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-integrated` marker so it runs once and never
  overrides a user who later disables startup (`dictate config set-startup off`).
  `dictate doctor` flags a launcher/startup entry whose `Exec` target is missing
  (e.g. a stale pip-era `~/.local/bin/dictate` after migrating to the package),
  and `dictate doctor --fix` repairs it.
