---
name: hecate-tauri
description: Use when working on the Hecate native desktop app in `tauri/`. Covers the Tauri 2.x Rust layer, sidecar lifecycle, platform bundling, and the gateway↔webview integration.
---

# Hecate Tauri skill

Use this skill for any work inside `tauri/`. Gateway changes use [`../backend/SKILL.md`](../backend/SKILL.md); UI changes use [`../ui/SKILL.md`](../ui/SKILL.md).

Operator-facing companion: [`../../../docs/desktop-app.md`](../../../docs/desktop-app.md) — distribution, current state, roadmap, footguns. When this skill grows a footgun an end user might hit, mirror it there.

## Canonical guidance lives here

- [`../../core/project-context.md`](../../core/project-context.md) — repo layout, toolchain pins, storage tiers.
- [`../../core/engineering-standards.md`](../../core/engineering-standards.md) — cross-project conventions.
- [`../../core/workflow.md`](../../core/workflow.md) — operating loop, commit etiquette.
- [`../../core/verification.md`](../../core/verification.md) — verification ladders and done criteria.

## Architecture in one paragraph

The Tauri app is a thin chrome-frame around the main Hecate runtime running in gateway mode. On launch the Rust layer allocates a free loopback port, spawns the `hecate` binary as a companion process (not a Tauri shell-plugin sidecar — see gotchas), polls `/healthz` until the gateway is healthy, then navigates the webview to `http://127.0.0.1:{port}/`. The gateway serves its own embedded React UI. No second frontend build, no API bridge, no Vite dev server required.

### Why we keep `//go:embed` instead of `frontendDist`

Most Tauri apps point `tauri.conf.json` → `frontendDist: "../ui/dist"` and let the webview load UI files directly from the bundled app. We don't, on purpose:

- **Same-origin loopback surface.** The webview loads from `http://127.0.0.1:{port}/` and the API is at `http://127.0.0.1:{port}/v1/...`. Same origin means no CORS dance and no Tauri IPC bridge. Splitting UI (`tauri://localhost`) from API (`127.0.0.1:{port}`) breaks that property and requires a meaningful security refactor to restore.
- **Hecate-sidecar property.** The same `hecate` runtime binary ships through Docker, the goreleaser tarballs, _and_ the Tauri sidecar. The embed makes that work without conditional builds or runtime path resolution. Reading `ui/dist` from disk inside a bundled `.app` would require a working-dir-independent resolver and a new "UI files missing" failure mode.
- **Bundle-size cost is small.** Embedding adds ~13 MB to the binary, which means a `.dmg` of ~25 MB instead of ~12 MB. Desktop apps routinely ship at 100+ MB; this isn't a real constraint.

If the gateway and UI ever decouple (gateway as pure API + separate static-server for UI), revisit this. Until then, keep the embed.

## Directory layout

```
tauri/
  package.json              bun project; @tauri-apps/cli v2 (bunx tauri dev/build)
  bun.lock
  splash/
    index.html              loading screen shown while the gateway boots
    fonts/                  vendored splash fonts + OFL notices; no runtime
                            network fetch during startup
  src-tauri/
    Cargo.toml              Rust crate: tauri 2, tauri-plugin-shell,
                            tauri-plugin-updater, tauri-plugin-window-state,
                            reqwest (rustls-tls), tokio
    Cargo.lock              committed (app, not a library)
    build.rs                tauri_build::build()
    tauri.conf.json         app config (identifier, window, externalBin, icons)
    capabilities/
      default.json          shell:allow-execute, shell:allow-kill
    gen/schemas/            Tauri 2 capability schemas — committed, needed at
                            compile time; regenerated by tauri dev/build
    icons/                  app icons: Linux PNGs, macOS .icns, Windows .ico
    binaries/
      .gitkeep              placeholder; real binary placed by just tauri-sidecar
    src/
      main.rs               thin entry point (#![cfg_attr(not(debug_assertions),
                              windows_subsystem = "windows")])
      lib.rs                Tauri builder, setup hook, GatewayChild managed state,
                            RunEvent::Exit → kill
      sidecar.rs            resolve_binary(), resolve_data_dir(), spawn_and_wait()
```

## Just recipes

| Recipe                  | What it does                                                                                                               |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `just tauri-install`    | `bun install` inside `tauri/`                                                                                              |
| `just tauri-version`    | runs `scripts/stamp-version.ts` — stamps Cargo.toml, package.json, tauri.conf.json to current git tag (or `TAURI_VERSION`) |
| `just tauri-sidecar`    | `just build` then copies `hecate` → `tauri/src-tauri/binaries/hecate-{triple}`                                             |
| `just tauri-dev`        | `tauri-sidecar` + `tauri-install` + `bunx tauri dev`                                                                       |
| `just tauri-build`      | `tauri-sidecar` + `tauri-version` + `bunx tauri build`                                                                     |
| `just test-tauri-smoke` | app-only Tauri build + launch packaged macOS app, probe `/healthz`, quit, verify sidecar exits                             |

Pass `TAURI_TARGET=universal-apple-darwin` (or any Rust target triple) to `tauri-build` for cross-compile. With just, env vars go before the command: `TAURI_TARGET=universal-apple-darwin just tauri-build`. Run `just tauri-sidecar` separately when you change Go code but not Rust — it's the fast path.

## Key modules

### `sidecar.rs`

Three responsibilities:

**`resolve_binary()`** — finds the `hecate` binary. Priority:

1. `HECATE_BIN` env var (override for CI / custom layouts)
2. Debug build: walks up from `CARGO_MANIFEST_DIR` (= `tauri/src-tauri`) → repo root → `hecate`
3. Release build: looks next to the running executable for `hecate-{TARGET}` (externalBin canonical name) then plain `hecate`

**`resolve_data_dir(app)`** — resolves the platform data directory via Tauri's path API, creates it if absent, passes it to the gateway as `HECATE_DATA_DIR`:

- macOS: `~/Library/Application Support/sh.hecate.app/`
- Windows: `%APPDATA%\sh.hecate.app\`
- Linux: `~/.local/share/sh.hecate.app/`

**`spawn_and_wait(app)`** — spawns the `hecate` runtime (via `std::process::Command`, not tokio — see gotchas), polls `/healthz` every 250 ms, returns `GatewayHandle { base_url, port, child }` on success.

### `lib.rs`

Tauri builder setup:

- Gets the `"main"` window (already created by `tauri.conf.json`) — does NOT call `WebviewWindowBuilder::new` (that would panic with "label already exists").
- Manages `GatewayChild(Mutex<Option<std::process::Child>>)` in Tauri app state.
- Spawns a `tauri::async_runtime` task: calls `spawn_and_wait`, stores the child, navigates the window.
- Uses `.build(...).run(|app, event| ...)` (not `.run(...)`) to intercept `RunEvent::Exit` and kill the child.

## Gateway↔webview integration

The webview loads `http://127.0.0.1:{port}/`. Because this is a loopback connection with no `Origin` header:

- The gateway treats the native app as the local operator surface.
- Browser same-origin checks still protect ordinary web traffic, but they are
  not a network security boundary.
- No auth token handoff is required in the current alpha.

## Binary resolver — dev vs release

| Context                   | How `hecate` is found                                                |
| ------------------------- | -------------------------------------------------------------------- |
| `just tauri-dev`          | `CARGO_MANIFEST_DIR/../../hecate` (repo root, built by `just build`) |
| `just tauri-build` bundle | `exe_dir/hecate-{TARGET}` (placed by Tauri's externalBin bundler)    |
| Any context               | `HECATE_BIN` env var wins if set                                     |

The `externalBin: ["binaries/hecate"]` entry in `tauri.conf.json` tells Tauri's bundler to include the sidecar binary. When signing is configured later, Tauri signs/notarizes bundled sidecars as part of the native app pipeline. `just tauri-sidecar` stages it as `binaries/hecate-{triple}` which is the name the bundler expects.

## Process lifecycle

- The hecate child is spawned with `stdin = null` and `stdout = null` so it doesn't fight the Tauri process for the terminal in dev mode; `stderr` is redirected to `<data_dir>/gateway.log` (truncated per launch) so startup failures stay diagnosable instead of vanishing.
- The `Child` handle is stored in `GatewayChild` managed state; the gateway's base URL is stored alongside it in `GatewayBaseURL` so the close-window flow can reach the gateway HTTP surface.
- The splash remains visible for at least 2 s before navigating to the gateway UI; keep this native-side so fast startups don't create a flash.
- `tauri-plugin-window-state` restores size and position between launches.
- **Quit funnel.** Every "user wants to quit" trigger (red-X `WindowEvent::CloseRequested`, `cmd+Q` / menu Quit `RunEvent::ExitRequested`) is intercepted and routed through `handle_quit_request`:
  1. `GET /hecate/v1/system/stats` → `running_runs`.
  2. If > 0, native confirmation dialog (`tauri-plugin-dialog`). "Keep running" cancels and clears the latch; "Quit anyway" continues.
  3. `POST /hecate/v1/system/shutdown` → poll `/healthz` until it stops responding (12 s deadline). The endpoint writes to a buffered quit channel that joins the same `select` as `SIGINT`/`SIGTERM` in `cmd/hecate/main.go`, so the gateway runs its existing drain (retention cancel, runner shutdown, HTTP server shutdown).
  4. `app.exit(0)`.
  - `QUIT_IN_PROGRESS` atomic latch keeps `app.exit(0)`'s re-entrant `ExitRequested` from re-prompting.
- `RunEvent::Exit` is the final cleanup: `child.try_wait()` for up to 2 s (graceful path leaves nothing to wait on) and then `child.kill()` as a fallback if the gateway never exited (drain timed out, /system/shutdown unreachable, etc.). Also removes `hecate.runtime.json` from the data dir.
- If the gateway fails to start within 30 s, the splash switches to a failure panel with the error, gateway log path, and data-dir path. The native Hecate menu can open both paths even when the gateway UI never loads.

## Tauri-specific rules

- **Never call `WebviewWindowBuilder::new(app, "main", …)` in setup.** The window is already created by `tauri.conf.json`; calling the builder again panics with "a webview with label `main` already exists". Use `app.get_webview_window("main")` instead.
- **`tauri.conf.json` `plugins.shell` does not accept a `sidecar` field.** In Tauri 2.x the shell plugin config only accepts `open`. Sidecar permissions are set in `capabilities/default.json`.
- **Use `std::process::Command`, not `tokio::process::Command`.** The `Child::kill()` and `Child::try_wait()` calls in `RunEvent::Exit` must be synchronous — the exit handler is not running in an async runtime. tokio's `Child` API is async and would need to be awaited, which isn't possible there.
- **`externalBin` uses the target triple suffix.** The binary must be named `hecate-{triple}` in `tauri/src-tauri/binaries/`. `just tauri-sidecar` does this automatically. The release-mode path resolver tries `hecate-{TARGET}` (compile-time constant) then plain `hecate`.
- **The gateway data directory must be writable.** A bundled `.app` on macOS is read-only. Always pass `HECATE_DATA_DIR` pointing to the Tauri-resolved app data directory — never let the gateway default to `.data/` next to its binary.
- **`gen/schemas/` is committed.** These files are generated by `tauri dev`/`tauri build` and needed at compile time for the capabilities system. Don't gitignore them.
- **`env!("TARGET")` requires `build.rs` forwarding.** Cargo sets `TARGET` for build scripts, not for the main crate compile. `tauri/src-tauri/build.rs` re-exports it via `cargo:rustc-env=TARGET=...` so `env!("TARGET")` resolves in `src/sidecar.rs`. Worked locally only because the incremental cache held the artifact; clean builds (CI) surface the error.
- **Updater install does not relaunch by itself.** The JS updater's `downloadAndInstall()` only downloads and applies the bundle. The UI must call `@tauri-apps/plugin-process` `relaunch()` afterwards, the Rust app must register `tauri_plugin_process::init()`, and `capabilities/default.json` must include `process:allow-restart`.
- **Icons must be format-correct, not just valid PNGs.** Renaming a `.png` to `.ico`/`.icns` makes the macOS bundler tolerate it, but Windows `RC.EXE` strictly validates ICO format and the build fails. Regenerate the full set with `bunx @tauri-apps/cli icon path/to/source.png` whenever artwork changes — it produces real `.ico` (multi-size MS Windows resource) and `.icns` (Mac OS X icon). Prune the iOS/Android/Microsoft Store outputs unless you actually ship to those platforms.
- **`permissions/autogenerated/` is committed**, same as `gen/schemas/`. `tauri-build` regenerates the per-command `allow-*`/`deny-*` TOML files there whenever you change `AppManifest::commands(&[...])` in `build.rs`. Commit the regenerated files — they're part of the ACL surface and reviewers should see them change when commands are added or renamed.

## Capabilities / ACL

Tauri 2 gates every IPC call (custom commands AND auto-injected runtime scripts like `drag.js`) through the capability system. The defaults are surprisingly restrictive — symptoms are usually silent: the call returns a rejected promise that nobody catches.

### Symptom → cause table

| Symptom                                                                                    | Likely cause                                                                                                                                                                                                              | Fix                                                                                                                    |
| ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `data-tauri-drag-region` doesn't drag the window on the gateway UI but works on the splash | The gateway origin (`http://127.0.0.1:{port}`) isn't an allowed remote URL for the capability                                                                                                                             | Add `"remote": { "urls": ["http://127.0.0.1:*/*"] }` to `capabilities/default.json`                                    |
| Drag doesn't work anywhere                                                                 | `core:window:allow-start-dragging` not in the capability's `permissions` (not in `core:default`)                                                                                                                          | Add it explicitly                                                                                                      |
| `invoke("my_command", …)` rejects with `"… not allowed. Plugin not found"`                 | App-defined commands have no ACL entry by default; on remote origins they're rejected                                                                                                                                     | Add to `AppManifest::commands(&[...])` in `build.rs` _and_ reference the generated `allow-<command>` in the capability |
| Webview console shows nothing on click but drag still fails                                | Tauri's `drag.js` reject is unhandled — promise rejection vanishes. Add temporary capture-phase `mousedown` listener in App.tsx that invokes `getCurrentWindow().startDragging()` with an explicit `.catch(console.warn)` | Diagnostic only; remove once the capability is right                                                                   |

### What `core:default` includes (and doesn't)

`core:default` is **not** "most things." It composes `core:path`, `core:event`, `core:window`, `core:webview`, `core:app`, `core:image`, `core:resources`, `core:menu`, and `core:tray` defaults — and each of those is its own narrow set. Notable omissions from `core:window:default`:

- `allow-start-dragging` — required for `data-tauri-drag-region`.
- `allow-start-resize-dragging` — required for custom resize handles.
- `allow-set-size`, `allow-set-position`, `allow-set-title`, … — anything that mutates the window.

When you add a feature, check the schema in `tauri/src-tauri/gen/schemas/acl-manifests.json` (`core:window.permissions` keys) before assuming a permission is included.

### Local vs remote origins

A capability's `local` flag (default `true`) makes it apply to local URLs (`tauri://localhost`, `frontendDist` pages, the splash). For HTTP-served pages (our gateway at `http://127.0.0.1:{port}/`), add `remote.urls` — URLPattern strings, e.g. `"http://127.0.0.1:*/*"`. Without it, every Tauri API the page tries to use silently fails because the capability never matches.

This is the reason drag works on the splash (local, file-backed by `frontendDist: "../splash"`) but not on the main UI (remote, served by the embedded gateway). The fix unblocks both `data-tauri-drag-region` and any `@tauri-apps/api/*` calls the UI makes (e.g. `useDesktopUpdate` invoking `set_update_badge`).

### Adding a custom command — ACL steps

1. Write the command in `lib.rs` with `#[tauri::command]`.
2. Register it in `Builder::invoke_handler(tauri::generate_handler![…])`.
3. **Add it to `AppManifest::commands(&[…])` in `build.rs`** so tauri-build emits `permissions/autogenerated/<command>.toml` with `allow-<command>` + `deny-<command>` entries.
4. Add `"allow-<command>"` (snake-case command name with the `allow-` prefix) to `capabilities/default.json`'s `permissions`.
5. If the gateway-served UI needs to call it (the common case), make sure `remote.urls` already covers `http://127.0.0.1:*/*`.

Skipping step 3 or 4 produces the "not allowed. Plugin not found" error from the JS side.

## Overlay titlebar (macOS)

The desktop window uses Tauri's macOS overlay titlebar — `tauri.conf.json` sets `decorations: true`, `hiddenTitle: true`, `titleBarStyle: "Overlay"`. The webview extends edge-to-edge under the invisible 28-px titlebar; the OS still owns drag/double-click but only for pixels the webview doesn't claim.

`titleBarStyle: "Overlay"` is macOS-only — Linux/Windows ignore it and render their native titlebar above the webview as usual. The whole overlay-titlebar arrangement below is therefore gated on macOS. On other platforms the strip is skipped and the update banner falls back to its old slot at the top of the workspace content (no 28-px inset, no traffic-light pad).

The relevant pieces live in `ui/src/app/`:

- **`App.tsx` — `installTauriDocumentMarkers()`** sets `<html data-tauri="true">` on first paint (run via `useLayoutEffect` — `useEffect` causes a one-frame "no titlebar inset" flash) and `<html data-tauri-os="macos">` when the platform is macOS.
- **`AppShell.tsx`** uses `isTauriOnMacOS()` (from `lib/tauri.ts`) to decide where to render `<UpdateBanner />`. On macOS it goes inside `<div className="hecate-titlebar" data-tauri-drag-region="deep">` — the first child of `.hecate-shell`. On Linux/Windows the strip isn't rendered and the banner sits at the top of `.hecate-content`. Same component, one slot per layout — no double-rendering.
- **`App.css`** reserves `padding-top: 28px` on the shell under `html[data-tauri-os="macos"]` (matched gating to the JSX), positions the titlebar with `position: absolute; inset: 0 0 auto 0; height: 28px; z-index: 1000; padding-left: 76px` to clear the traffic-light cluster, and sets `user-select: none` so window drags don't smear a text selection.

### Rules

- **`data-tauri-drag-region` values matter.** Bare attribute / `="true"` / `=""` triggers drag _only_ when the click target IS that element — a click on any child doesn't drag. Use `="deep"` when you wrap interactive content (titlebar with banner buttons) so the empty parent areas drag while children stay live. Tauri's drag.js auto-detects clickable elements (`<button>`, `<input>`, `<a>`, things with `role=button`, `contenteditable`, non-`-1` `tabindex`) and skips drag for them; no `-webkit-app-region: no-drag` opt-out needed.
- **Don't rely on CSS `-webkit-app-region: drag` on macOS.** Modern WKWebView doesn't honor it in Tauri 2; the JS-based `data-tauri-drag-region` path (gated on `core:window:allow-start-dragging`) is the one that actually works.
- **Set `user-select: none` and `cursor: default` on the titlebar.** Without them, dragging from over the banner text leaves a selection smear and the I-beam cursor flickers.
- **Splash drag works without the remote-URL capability** because `frontendDist: "../splash"` is a local origin. The main UI needs `remote.urls` (see the Capabilities / ACL section).

## Adding a new Tauri feature

Before adding Tauri commands (`#[tauri::command]`) or IPC:

- Check if the gateway REST API already covers the use case. The webview can call `http://127.0.0.1:{port}/v1/…` directly — no IPC needed for gateway operations.
- Use IPC only for truly desktop-native concerns: file dialogs, OS notifications, system tray, auto-update triggers, deep links.

Adding a Tauri command: see the "Adding a custom command — ACL steps" sub-section under [Capabilities / ACL](#capabilities--acl) above. The five steps there cover macro registration, the build-script `AppManifest::commands` entry that auto-generates the permission, and the capability wiring.

## Verification

```bash
# Rust type check (fast, no binary output)
cd tauri/src-tauri && cargo check

# Full debug build + launch (builds gateway first)
just tauri-dev

# Release bundle for current platform
just tauri-build

# Release bundle for macOS universal binary
TAURI_TARGET=universal-apple-darwin just tauri-build

# Opt-in native lifecycle smoke test (opens the packaged macOS app)
just test-tauri-smoke
```

`cargo check` is the fast iteration loop. Full `tauri dev` is the integration test — it exercises the real binary, real port allocation, real healthz poll, and the webview navigation.
`just test-tauri-smoke` is the packaged-app lifecycle check for macOS; it is
not part of `verify` because it opens a GUI window.

## CI pipeline

Three workflow files split responsibilities:

| File                                  | Trigger              | What it does                                                                                                 |
| ------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------ |
| `.github/workflows/_tauri-shared.yml` | `workflow_call` only | Reusable matrix build. Single source of truth for Tauri build steps.                                         |
| `.github/workflows/release.yml`       | tag push (`v*`)      | Goreleaser job, then calls `_tauri-shared.yml` with `tagName` set → bundles upload to the GitHub Release.    |
| `.github/workflows/test.yml`          | PR / `master` push   | Main test gate. Its `Tauri desktop bundles` job calls `_tauri-shared.yml` after cheaper checks pass or skip. |
| `.github/workflows/tauri-build.yml`   | manual dispatch      | Explicit desktop rebuild/debug run from the Actions tab.                                                     |

**Matrix** (same for both callers):

| Runner           | Rust target                | Bundles produced    |
| ---------------- | -------------------------- | ------------------- |
| `macos-latest`   | `aarch64-apple-darwin`     | `.dmg`              |
| `ubuntu-22.04`   | `x86_64-unknown-linux-gnu` | `.deb`, `.AppImage` |
| `windows-latest` | `x86_64-pc-windows-msvc`   | `.msi`              |

**Steps per matrix leg** (in `_tauri-shared.yml`): Rust + Go + Bun setup → Linux Tauri 2 prereqs (webkit2gtk-4.1, libxdo, patchelf, …) → `just ui-install` → `just tauri-sidecar <matrix-target>` (builds `hecate` / `hecate-acp` with the tag injected, then stages them as `binaries/<name>-{triple}[.exe]`) → `cd tauri && bun install --frozen-lockfile` → `bun scripts/stamp-version.ts` → `tauri-apps/tauri-action@v0` (build, conditionally upload).

**PR-run behaviour:** `test.yml` owns PR validation. Its path filter scopes the
desktop matrix to changes that could plausibly break a Tauri build (`tauri/**`,
`cmd/hecate/**`, `cmd/hecate-acp/**`, `Justfile`, version scripts, release
packaging files, and `.github/workflows/*.yml`). UI-only PRs do not trigger the
desktop matrix; they are covered by the TypeScript jobs and by `build-hecate`,
which rebuilds the embedded UI. The desktop matrix waits for the cheaper Go,
TypeScript, e2e, Docker smoke, and Tauri Rust jobs to pass (or skip by path
filter) before it starts, and it does not upload unsigned bundles.
`concurrency: cancel-in-progress: true` cancels older runs on the same ref.
`tauri-build.yml` is manual-only for explicit desktop reruns/debugging; dispatch
it from a PR branch when a reviewer needs a pre-merge bundle to test-launch.

**Release-run behaviour:** `concurrency: cancel-in-progress: false` — a half-cancelled release is worse than waiting. The `tauri` job has `needs: goreleaser` so the GitHub Release entry exists before tauri-action's upload tries to attach to it.

### Pipeline footguns

- **`tauri-action`'s auto-install is unreliable with `projectPath` set.** It silently skips `bun install` in `tauri/`, leaving `node_modules/.bin/tauri` missing — `bun run tauri build` then fails with "tauri: command not found". Always run `cd tauri && bun install --frozen-lockfile` ourselves before the action.
- **`build.rs` validates `externalBin` paths at build-script run time.** Any step that compiles the Rust crate (`cargo check`, `cargo build`, `tauri build`) needs the sidecar staged at `binaries/hecate-{triple}[.exe]` first. Run `just tauri-sidecar <target>` before any Rust compile; it builds and stages the versioned sidecars in one step. Plain `just build` leaves the runtime version as `dev`; release/native bundles need the versioned sidecar path so `/healthz` and the UI status bar match the Tauri app version.
- **Just + Git Bash on Windows runners.** Set `defaults.run.shell: bash` job-wide. Default Windows shell is PowerShell; the Justfile shell recipes and our `cp`/`if` blocks only work in bash. On Windows runners, `bash` resolves to Git Bash.
- **Go sidecar names differ by host and bundle target.** `just tauri-sidecar <target>` reads `go env GOEXE`, then stages `hecate$GOEXE` and `hecate-acp$GOEXE` to `binaries/<name>-<target>$GOEXE`. A missing source fails at `cp`, which usually means the versioned sidecar build failed earlier in the recipe.
- **`just tauri-build-sidecars` runs `just ui-build` which checks for `ui/node_modules/@vitejs/plugin-react`.** That's the canary file. CI must run `just ui-install` before building Tauri sidecars. Goreleaser handles the normal gateway build via its `before:` hook (`bun install --cwd ui --frozen-lockfile`); the Tauri matrix does it explicitly.
- **`TAURI_VERSION` may include the `v` prefix.** `scripts/resolve-tauri-version.ts` normalizes either `v0.1.0-alpha.N` or `0.1.0-alpha.N`; both `stamp-version.ts` and `just tauri-build-sidecars` use that shared resolver so Tauri metadata and Go sidecar versions stay aligned.
- **Windows MSI rejects pre-release identifiers in the version.** WiX requires a four-part numeric `Major.Minor.Build.Revision` (each ≤ 65535). `0.1.0-alpha.8` fails with "optional pre-release identifier in app version must be numeric-only". `stamp-version.ts` writes a derived four-part version to `bundle.windows.wix.version` (e.g. `0.1.0-alpha.8` → `0.1.0.8`); MSI uses that override, every other bundler still sees the canonical semver. NSIS has no version-override field in Tauri's schema — the matrix is MSI-only on Windows for that reason. If NSIS is ever added, expect the same failure mode and find an upstream fix.

### Code signing — macOS conditional, Windows not yet wired

macOS bundles are signed with a Developer ID Application certificate and notarized by Apple **on release-workflow runs when the seven `APPLE_*` / `KEYCHAIN_PASSWORD` repo secrets are configured**. "Release-workflow run" = any invocation of `release.yml` (tag push or `workflow_dispatch`); both pass a non-empty `tagName` to the reusable workflow. The CI workflow in `.github/workflows/_tauri-shared.yml` reads the secrets via env, gated on two conditions in series: `matrix.os == 'macos-latest'` AND `inputs.tagName != ''`. PR validation in `test.yml` and manual `tauri-build.yml` rebuilds deliberately do not use `secrets: inherit`, so they always produce unsigned bundles by design — they're throwaway artifacts. Maintainer-side setup checklist for the secrets is in [`docs/macos-signing.md`](../../../docs/macos-signing.md), including a verification recipe and a rotation playbook. Operators downloading an unsigned build (PR validation, fork builds, releases cut before secrets landed) need right-click → Open on first launch.

Windows MSI signing (Authenticode + EV cert ~$300+/yr) is not yet wired. SmartScreen warns until it lands; document in release notes.

### Auto-update

End-to-end signed-update pipeline, active.

- `tauri-plugin-updater` is loaded in `lib.rs` and granted via `updater:default` in `capabilities/default.json`.
- `tauri.conf.json` has `updater.active: true` with the maintainer pubkey, and `updater.endpoints` pointing at `https://hecate.sh/releases/alpha/latest.json`.
- The frontend has `useDesktopUpdate()` (in `ui/src/lib/desktop-update.ts`) — silently calls `check()` on app start in the Tauri runtime, returns the version + a download progress fraction. `<UpdateBanner>` (in `ui/src/features/shared/UpdateBanner.tsx`) shows "Hecate X.Y.Z is available — Install and Restart / Dismiss" with a live progress bar during install. Dismissed banners stay dismissed for the session via `sessionStorage`.
- `_tauri-shared.yml` runs a three-step signed-release pipeline. Per matrix leg: tauri-action receives `TAURI_UPDATER_PRIVATE_KEY` + `TAURI_UPDATER_PRIVATE_KEY_PASSWORD` (gated on `inputs.tagName != ''` — same caller-side / called-side protection model as the Apple secrets) and signs the platform bundles; a follow-up step uploads the `.sig` files and macOS `Hecate.app.tar.gz` to the release. Post-matrix: the `publish-updater-manifest` job stitches the per-platform signatures into `latest.json` and uploads it to the GitHub Release. Then `publish-updater-website` commits the same manifest to `website/public/releases/alpha/latest.json`, dispatches `website.yml`, and waits until `https://hecate.sh/releases/alpha/latest.json` serves the new version. tauri-action can't build the manifest in matrix mode because each leg only sees its own signature. The bundler also needs `bundle.createUpdaterArtifacts: "v1Compatible"` in `tauri.conf.json` — without it no updater payloads get produced, and the stitch job fails loudly on `missing updater signature(s)`. Existing installs verify the signature against the embedded pubkey before surfacing the banner.

Maintainer-side keypair custody and rotation playbook: [`docs/desktop-updater-signing.md`](../../../docs/desktop-updater-signing.md).

Outside the Tauri runtime (web build, Docker, bare-binary serving the embedded UI), the hook is inert — `isTauriRuntime()` from `ui/src/lib/tauri.ts` gates the dynamic import so the plugin code never enters non-desktop bundle paths.

## Done criteria

- `cargo check` passes with no warnings.
- `just tauri-dev` launches the window, shows the splash, navigates to the gateway UI, and auto-logs in without a token paste.
- Closing the window — red-X, `cmd+Q`, or menu Quit — leaves no `hecate` process running (`pgrep hecate` returns nothing after quit), via the graceful drain path. Running tasks surface a confirmation dialog before being interrupted.
- See [`../../core/verification.md`](../../core/verification.md) for the full ladder.
