| 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; UI changes use ../ui/SKILL.md.
Operator-facing companion: ../../../docs/operator/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
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 negligible. The built UI is ~730 KB on disk (~200 KB gzipped over the wire). Embedding it adds ~2% to the
hecate binary (32 MB → 33 MB) and ~1 MB to the .dmg. Desktop apps routinely ship at 100+ MB; this isn't even close to a constraint, and any "ship a UI-less variant" optimization would save kilobytes for a meaningful maintenance cost.
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-opener,
tauri-plugin-updater, tauri-plugin-window-state,
reqwest (rustls-tls), tokio
Cargo.lock committed (app, not a library)
build.rs tauri_build::build()
Info.plist macOS privacy purpose strings merged into the bundle
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()
cloud_connection.rs desktop-only Hecate Cloud account, credential,
host registration, and outbound relay supervisor
Just recipes
| Recipe | What it does |
|---|
just tauri-install | bun install --frozen-lockfile inside tauri/ |
just tauri-version | runs scripts/stamp-version.ts — stamps desktop and mobile version metadata to the 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:
HECATE_BIN env var (override for CI / custom layouts)
- Debug build: walks up from
CARGO_MANIFEST_DIR (= tauri/src-tauri) → repo root → hecate
- 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 native wrapper writes lifecycle breadcrumbs to
app.log: app startup,
sidecar spawn/readiness, navigation, update-check dispatch, badge failures,
Cloud connector start/stop, graceful drain, fallback kill, runtime-state
cleanup, and ACP adapter process/session lifecycle. When debugging desktop
issues, inspect app.log for wrapper behavior and gateway.log for gateway
stderr.
- 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:
GET /hecate/v1/system/stats → running_runs.
- If > 0, native confirmation dialog (
tauri-plugin-dialog). "Keep running" cancels and clears the latch; "Quit anyway" continues.
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).
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.
- Desktop remote access is native Rust plumbing in
cloud_connection.rs; it
must not depend on an installed hec binary. The app generates an app-session
token, opens Hecate Cloud approval in the system browser, registers this
computer, and keeps the outbound host WebSocket alive while the app runs.
hec connect remains a separate headless/manual connector that shares the
Cloud transport protocol; do not assume its local authentication posture is
interchangeable with the native app's per-launch sidecar boundary.
- Cloud session and host tokens live only in the OS credential store via
keyring. <app_data_dir>/cloud-connection.json contains reconnect posture,
account actor/display identity, org id, and host id, but never tokens,
secrets, or the approval URL. The approval token stays in Rust and the system-browser URL
fragment; never return it through Tauri IPC to the webview.
- Generate a fresh remote-runtime secret for each app launch, pass it to the
loopback sidecar as
HECATE_REMOTE_RUNTIME_SECRET, and retain it only in Rust
memory. Relay requests must stamp the authenticated Cloud actor, org, and host
identity after filtering browser-provided headers. Never expose the secret or
accept remote identity through Tauri IPC.
- Launch the native sidecar with
HECATE_PERSONAL_REMOTE_EXTERNAL_AGENT_LOGINS=1. The first-party Desktop
connector is a single-user personal-runtime boundary, so an authenticated
remote request may reuse that desktop user's External Agent CLI login state.
Keep this posture local to the native Desktop sidecar; do not silently extend
it to hosted runtimes or the standalone hec connect flow.
- Rust owns Cloud login, credential storage, reconnect, transport validation,
body limits, and response streaming. Go owns remote endpoint authorization.
Do not add a second Hecate route allowlist to
cloud_connection.rs; every
relayed API request must acquire remote identity so the canonical Go policy
applies.
- Turning Remote access off cancels the relay and disables reconnect without
signing the account out. Signing out revokes the registered host and app
session where possible, removes both keychain entries, and clears local Cloud
preferences. App exit only closes the relay; it preserves the enabled bit so
the next launch reconnects after the sidecar is healthy.
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.
- Use
tauri-plugin-opener for system-browser URLs. Do not add the general
shell plugin just to open a login URL. The bundled Hecate runtime is declared
through externalBin; it is launched by the Rust sidecar supervisor, not a
plugins.shell.sidecar configuration field.
- 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. On macOS, the install promise can stall after the download reaches 100%; keep the renderer watchdog in ui/src/lib/desktop-update.ts so Hecate still relaunches instead of pinning the update details forever. Distinguish a download/install failure from an installed-but-restart-failed state: only the former may retry the download.
- 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.
- Webview media access is platform-owned, not a Tauri ACL. Provider-routed
chat dictation uses browser
getUserMedia through the loopback webview;
client dictation uses Web Speech where the webview exposes it. Neither needs
a custom Tauri command or capability, and audio must not move through Tauri
IPC. The browser-managed route may use the webview vendor's cloud and must be
labelled accordingly; do not invoke experimental static language-pack probes
during composer startup. macOS requires both NSMicrophoneUsageDescription
and NSSpeechRecognitionUsageDescription in src-tauri/Info.plist; denied
browser-managed recognition may require enabling Hecate in both Microphone
and Speech Recognition under Privacy & Security. Windows uses WebView2's
site permission flow. WebKitGTK denies unhandled user-media
requests, so webview_media installs a native signal handler that grants only
audio-only requests while the active document matches the exact sidecar
origin stored in GatewayBaseURL and the owned GatewayChild is still
running. Do not broaden this to camera, wildcard loopback ports, the splash
origin, stale child state, or arbitrary permissions. macOS Dictation and
Windows voice typing can enter text through the focused composer without a
Tauri command; that path remains OS-owned. Text-to-speech/read-aloud is a
separate output capability. Tauri merges the macOS plist into the bundle;
keep its purpose string user-facing and verify generated bundles when
platform behavior changes.
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
- Write the command in
lib.rs with #[tauri::command].
- Register it in
Builder::invoke_handler(tauri::generate_handler![…]).
- Add it to
AppManifest::commands(&[…]) in build.rs so tauri-build emits permissions/autogenerated/<command>.toml with allow-<command> + deny-<command> entries.
- Add
"allow-<command>" (snake-case command name with the allow- prefix) to capabilities/default.json's permissions.
- 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 (no 28-px inset, no traffic-light pad). The desktop update control belongs in the shared status bar on every host, never in the titlebar or workspace content.
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 data-tauri-os="macos|windows|linux" for small host-aware shell styling.
AppShell.tsx uses isTauriOnMacOS() (from lib/tauri.ts) only for the empty <div className="hecate-titlebar" data-tauri-drag-region="deep">. <DesktopUpdateCenter /> renders exactly once in .hecate-statusbar on every host and owns one useDesktopUpdate() lifecycle.
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. Portal-backed dialogs use a higher z-index than the titlebar. styles.css uses the same host marker for local system typography; do not reintroduce a remote font fetch into the desktop startup path.
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" for the macOS titlebar so its whole empty strip drags. Keep controls out of that strip; Tauri's drag.js auto-detects clickable elements (<button>, <input>, <a>, things with role=button, contenteditable, non--1 tabindex) but a clean titlebar is simpler and more native.
- 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 can leave a selection smear and make the I-beam cursor flicker.
- 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 above. The five steps there cover macro registration, the build-script AppManifest::commands entry that auto-generates the permission, and the capability wiring.
Verification
cd tauri/src-tauri && cargo check
just tauri-dev
just tauri-build
TAURI_TARGET=universal-apple-darwin just tauri-build
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 tauri-rust-test runs the Rust test suite with a placeholder sidecar; use it for Tauri Rust changes that don't need a real gateway binary.
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
Six workflow files split responsibilities:
| File | Trigger | What it does |
|---|
.github/workflows/_tauri-shared.yml | workflow_call only | Reusable desktop matrix and signed updater publication. |
.github/workflows/_tauri-mobile-shared.yml | workflow_call only | Reusable unsigned iOS Simulator and Android debug compilation. |
.github/workflows/release.yml | tag push (v*) | Runs the mobile preflight, Goreleaser, then _tauri-shared.yml for desktop Release uploads. |
.github/workflows/test.yml | PR / master push | Main test gate. Required desktop and mobile callers start after cheaper checks pass or skip. |
.github/workflows/tauri-build.yml | manual dispatch | Explicit desktop rebuild/debug run from the Actions tab. |
.github/workflows/tauri-mobile-build.yml | manual dispatch | Uploads a short-lived simulator archive and Android debug APK for testing. |
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 |
Mobile compile matrix:
| Runner | Target | Output |
|---|
macos-latest | aarch64-apple-ios-sim | unsigned iOS Simulator Hecate.app |
ubuntu-24.04 | aarch64-linux-android / arm64 | runner-debug-signed Android universal APK |
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 with the tag injected, then stages it as binaries/hecate-{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 filters scope the
desktop and mobile matrices to changes that could plausibly break their Tauri
builds (tauri/**, cmd/hecate/**, Justfile, just/**, version scripts,
release packaging files, and .github/workflows/*.yml). UI-only PRs do not
trigger either matrix; they are covered by the TypeScript jobs and by
build-hecate, which rebuilds the embedded UI. Both native matrices wait for
the cheaper Go, TypeScript, e2e, Docker smoke, and Tauri Rust jobs to pass (or
skip by path filter) before they start. They run in parallel and do not upload
PR artifacts.
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.
tauri-mobile-build.yml is the equivalent mobile workflow and preserves the
iOS app in a ditto archive so its executable modes survive artifact download.
Release-run behaviour: concurrency: cancel-in-progress: false — a
half-cancelled release is worse than waiting. Release ref validation runs first,
then the unsigned mobile matrix. goreleaser cannot publish until both mobile
targets compile. The desktop tauri job still 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 a sidecar file staged at binaries/hecate-{triple}[.exe] first. For Rust-only tests/checks, run just tauri-test-sidecar or just tauri-rust-test; those stage a lightweight placeholder because the gateway is not executed. For real dev/build/release bundles, run just tauri-sidecar <target> so /healthz and the UI status bar report the packaged Tauri version instead of dev.
- 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 to binaries/hecate-<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.
- The release stamp commit includes mobile metadata.
stamp-version.ts also writes tauri.ios.conf.json, tauri.android.conf.json, gen/apple/project.yml, and the generated iOS Info.plist. Keep the explicit git add list in scripts/release.ts aligned with every file the stamper can mutate so a release tag never disagrees with its store artifacts.
- 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/operator/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.
macOS Apple Silicon is the only desktop path maintainers currently launch-test.
Linux .deb / .AppImage and Windows .msi bundles are CI-built but have not
yet been manually exercised on real machines; keep README, release notes, and
docs honest about that. Windows MSI signing (Authenticode + EV cert ~$300+/yr)
is not yet wired, so SmartScreen warnings are expected once the MSI is tested.
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) and one <DesktopUpdateCenter /> in the status bar. The controller silently checks on app start, keeps manual check outcomes until the dialog closes, owns the Tauri Update resource until a successful install releases it, exposes current/available versions and progress, and preserves available-update metadata after an install failure. A native-menu check is recorded as a coalesced Rust intent before its event is emitted; the renderer consumes take_pending_desktop_update_check after registering its listener, so a request made during the splash screen still opens the dialog. Automatic discovery only illuminates the compact control. Render the dialog through a portal to document.body above the macOS titlebar, and render the bounded release notes through the shared React-node Markdown renderer: raw HTML remains text, unsafe link schemes remain inert, and Markdown images never trigger remote fetches. Desktop HTTP(S)/mailto links open through the narrowly scoped Tauri opener instead of navigating the main webview. Update.body / manifest notes are advisory metadata, not signed; say that the downloaded package is verified before installation instead of claiming the notes or availability were verified. Dismissed updates stay dismissed for the session via sessionStorage. macOS uses a dock badge, Linux tries a count badge, and Windows deliberately relies on the status-bar cue because taskbar counts are unsupported.
_tauri-shared.yml owns signed packaging. 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 only recognized updater sidecars plus macOS Hecate.app.tar.gz to the release. Post-matrix, publish-updater-manifest stitches the per-platform signatures into latest.json, fetches the bounded GitHub Release body/date as advisory notes / pub_date, and uploads it to the GitHub Release without logging the full notes body. release-delivery.yml then validates that canonical asset, refreshes the release docs, validates the website, and uploads an allowlisted patch plus provenance. A maintainer applies the patch on current master and opens the protected delivery PR; merging the checked PR triggers website.yml, whose deploy job waits until https://hecate.sh/releases/alpha/latest.json matches the committed manifest's exact SHA-256. Version is diagnostic only, so a same-tag correction to signatures or URLs cannot pass against stale CDN bytes. This handoff needs no repository-write token, App/PAT secret, or branch-rules bypass. 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 downloaded payload against the embedded pubkey before installation.
Maintainer-side keypair custody and rotation playbook: docs/operator/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 for the full ladder.