Files
Skald-Circle/docs/desktop.md
T
2026-07-10 15:02:09 +01:00

283 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Desktop bundle (Tauri)
Skald ships in **two shapes** from the same source tree, selected by the
`desktop` cargo feature:
| Shape | How it runs | How the user reaches it | Typical host |
| --- | --- | --- | --- |
| **Headless server** (default, no `desktop` feature) | The binary blocks on its own tokio runtime, as before. | Browser on `http://<host>:<port>` (LAN-friendly, binds `0.0.0.0`). | Linux server, dev workstation, Docker container. |
| **Desktop bundle** (`--features desktop`) | A Tauri event loop wraps the same headless backend; the backend runs as a task on Tauri's shared tokio runtime. A system-tray icon exposes `Open` / `Quit`; the webview loads `http://127.0.0.1:<port>`. | Double-click the `.app` / `.exe` / `.AppImage`. | End-user macOS / Windows / Linux machine. |
The `desktop` feature is **opt-in and default-off**: every existing build path
(`cargo build`, `run.sh`, `Dockerfile`) is unchanged because `--no-default-features`
already excludes it.
---
## Build
### Dev mode (debug, no bundle)
```sh
cargo run --features desktop
```
A real Tauri window + tray icon appears. The webview loads
`http://127.0.0.1:<config-port>` from the running dev binary; the binary reads
`config.yml`, `agents/`, `skills/`, `web/` from the crate root exactly as the
headless build does (the cwd is **not** relocated in dev mode — see
*Data directory* below).
### Distributable bundle (release, packaged)
```sh
cargo tauri build --features desktop
```
This requires the `tauri-cli`:
```sh
cargo install tauri-cli --version "^2"
```
Produces native installers under `target/release/bundle/`:
| OS | Artifact |
| --- | --- |
| macOS | `Skald.app`, `Skald.dmg` |
| Windows | `Skald.msi`, `Skald.exe` (NSIS) |
| Linux | `Skald.deb`, `Skald.AppImage` |
> **macOS deployment target.** The default `whisper-local` feature compiles
> `whisper.cpp` (C++), whose `ggml-backend-reg.cpp` uses
> `std::filesystem::path` — introduced in macOS 10.15. `cargo tauri build`
> injects `MACOSX_DEPLOYMENT_TARGET` from
> `tauri.conf.json > bundle.macOS.minimumSystemVersion` (Tauri's default is
> `"10.13"`), on which `std::filesystem::path` is marked *unavailable*, so the
> ggml build fails with ~20 `'path' is unavailable` errors.
>
> The fix has **three coordinated pieces**, all pinned to `"10.15"` (the exact
> floor that unblocks `std::filesystem`):
>
> 1. **`.cargo/config.toml`** sets **two** forced env vars. Both are needed
> because the C++ compile otherwise ends up with two conflicting
> `-mmacosx-version-min` flags and clang lets the *last* one win:
> - `MACOSX_DEPLOYMENT_TARGET` → the `cc` crate turns this into the CFLAGS'
> `-mmacosx-version-min`.
> - `CMAKE_OSX_DEPLOYMENT_TARGET` → `whisper-rs-sys`'s `build.rs` forwards any
> `CMAKE_*` env var to cmake as `-DCMAKE_OSX_DEPLOYMENT_TARGET=…`, which
> sets CMake's *own* `-mmacosx-version-min` **and overrides a value cached
> in a stale `CMakeCache.txt`**. Without this, CMake's cached 10.13 wins.
>
> `force = true` makes cargo override whatever the Tauri CLI injects
> (`MACOSX_DEPLOYMENT_TARGET=10.13`), so the C/C++ build is
> **deterministically** 10.15 regardless of Tauri's env-propagation. It also
> gives the headless binary a 10.15 portability floor.
> 2. **`tauri.conf.json > bundle.macOS.minimumSystemVersion = "10.15"`** — the
> value baked into the bundle's `Info.plist` as `LSMinimumSystemVersion`.
> 3. If a build still fails after a target change, **wipe the stale CMake
> caches**: `rm -rf target/*/build/whisper-rs-sys-*` (`cargo clean -p
> whisper-rs-sys` does not always clear the cmake `out/` dir). Piece 1's
> explicit `-D` flag makes this rarely necessary, but it is the escape hatch.
>
> Keep pieces 1 and 2 in sync. Raise all to `"11.0"` / `"12.0"` only if you want
> an Apple-Silicon-only bundle.
For cross-platform CI builds use a GitHub Actions matrix (the
[`tauri-apps/tauri-action`](https://github.com/tauri-apps/tauri-action) is the
canonical setup).
---
## Architecture
```
┌────────────────── main.rs ──────────────────┐
│ install rustls ring provider │
│ init_logging() │
│ │
│ ┌── cfg(feature = "desktop") ──┐ │
│ │ desktop::run() │ │
│ │ tauri::Builder │ │
│ │ .setup(spawn backend) │ │
│ │ .on_window_event(hide) │ │
│ │ .run(ExitRequested→ │ │
│ │ shutdown_backend) │ │
│ └───────────────────────────────┘ │
│ ┌── cfg(not(feature = "desktop")) ─┐ │
│ │ tokio runtime → async_main() │ │
│ │ run_backend() │ │
│ │ wait_for_shutdown_signal() │ │
│ │ shutdown_backend() │ │
│ └───────────────────────────────────┘ │
└──────────────────────────────────────────────┘
```
### Module map
| Path | Role |
| --- | --- |
| `src/main.rs` | Dispatcher. Installs the rustls provider, sets up tracing, then branches on the `desktop` feature. Exposes `run_backend()` and `shutdown_backend()` shared by both entry points. |
| `src/desktop/mod.rs` | Tauri shell. Builds the system tray + menu, creates the main `WebviewWindow` (URL derived from `config.yml`'s `server.port`), spawns the backend on Tauri's shared tokio runtime, and handles graceful shutdown. Compiled only under `--features desktop`. |
| `src/config.rs` | `bootstrap_data_dir()` relocates the cwd to a per-user data dir when running inside a `.app` bundle (no-op in dev mode and headless mode). |
| `src/core/tools/restart.rs` | Branches on the feature: under `desktop`, restarts the Tauri process (cleanup + respawn the same binary, **no rebuild** — the bundle is read-only); otherwise `libc::_exit(-1)` so `run.sh` rebuilds. |
| `build.rs` | Calls `tauri_build::build()` under the `desktop` feature; no-op otherwise. |
| `tauri.conf.json` | Tauri bundle config: identifier, icons, security. The main window is **not** declared here — it is built programmatically in the setup hook so the URL can read the backend port from `config.yml`. |
| `capabilities/default.json` | Tauri v2 capability set for the `main` window (window show/hide/focus permissions). The frontend never invokes Tauri's JS API — it is the regular Skald web app served by Axum. |
| `icons/` | App icon (`icon.png`, `32x32.png`, `128x128.png`, `128x128@2x.png`, `icon.icns`, `icon.ico`) and a monochrome tray template (`tray-template.png`). See *Icons* below. |
### Why a single binary, not a launcher
`skald` is a binary crate (`src/main.rs`, no `src/lib.rs`). Putting the Tauri
shell in `src/desktop/` behind `#[cfg(feature = "desktop")]` — instead of a
separate crate — keeps everything private (no `pub` leakage) and lets
`tauri::generate_context!()` find `tauri.conf.json` in `CARGO_MANIFEST_DIR`.
### Why Tauri's runtime, not a fresh tokio
`tauri::async_runtime::spawn` lands the task on Tauri's internal tokio
runtime. One runtime, one process, no IPC bridge — the webview talks to the
backend over plain HTTP/WS on `127.0.0.1`, exactly like a browser would.
---
## System tray + window policy
* A single tray icon is built in `build_tray()` with two menu items: **Open**
and **Quit**.
* **Left-click** the tray icon toggles the main window (show+focus or hide).
* **Open** menu item shows + focuses the main window.
* The window's close button (traffic-light red on macOS, X on Windows/Linux)
is intercepted in `on_window_event``WindowEvent::CloseRequested` and
turned into a `hide()`. The app keeps running in the tray.
* **Quit** (and Cmd+Q / system shutdown) triggers `RunEvent::ExitRequested`.
The handler spawns `shutdown_backend()` on the async runtime, waits for it
to drain the HTTP server / Skald managers / DB pool, then calls
`app.exit(0)`. An `AtomicBool` re-entrancy guard prevents the second
`ExitRequested` (emitted by the `app.exit(0)` itself) from looping.
---
## Data directory
| Mode | Working directory at startup |
| --- | --- |
| Headless (`cargo run`, `run.sh`, Docker) | Whatever the user launched from (today, the crate root). Unchanged. |
| Desktop dev (`cargo run --features desktop`) | The crate root — same as headless, so `config.yml`, `agents/`, `skills/`, `web/` resolve from the source tree. |
| Desktop bundle (inside `Skald.app/Contents/MacOS/`) | Relocated to the OS per-user data dir, see table below. |
When packaged as a `.app` (or Windows / Linux equivalents), the process cwd is
typically `/`. `bootstrap_data_dir()` detects this (via
`running_from_bundle()` — a `.app/` heuristic on macOS, currently always-false
on Windows/Linux until those targets land) and `set_current_dir`s to the
per-user data dir:
| OS | Location |
| --- | --- |
| macOS | `~/Library/Application Support/Skald` |
| Windows | `%APPDATA%\Skald` (= `C:\Users\<u>\AppData\Roaming\Skald`) |
| Linux | `~/.local/share/Skald` |
This makes every relative path in `config.yml` (`db.path`, `web.static_dir`,
`data/`, `secrets/`, `models/`, …) resolve under the user's data dir without
touching the source tree.
If `config.yml` is missing on first launch, it is seeded from
`DEFAULT_CONFIG_EMBEDDED` (the `default.config.yaml` baked into the binary via
`include_str!`).
**Logs.** `init_logging()` runs *before* the cwd is relocated, so a relative
`logs/` would land against the launch cwd (`/` for a Finder-launched `.app`) and
silently fail to write. `config::resolved_log_dir()` returns an **absolute**
path under the data dir (`~/Library/Application Support/Skald/logs`) in a bundle,
so logs always land in a writable place; headless and desktop-dev keep the
relative `logs/`.
### Read-only bundled assets
The relocated data dir holds only **mutable** state (db, config, logs, secrets,
models, uploads). The **read-only** assets the backend needs — `agents/`,
`web/`, `skills/`, `commands/` — are packaged into the bundle's `Resources/`
dir via `tauri.conf.json > bundle > resources`, and made reachable from the
relocated cwd by `link_bundled_assets()` (in `src/config.rs`), which runs right
after the relocation:
* For each asset name it creates a **symlink** in the data dir pointing at the
copy inside `…/Skald.app/Contents/Resources/<name>`. Symlinking (not copying)
keeps the assets in lock-step with the installed app version.
* A pre-existing **real** directory in the data dir is treated as a user
override and left untouched; only stale symlinks are refreshed each launch.
Without this step `Skald::new` fails on launch with *"Failed to read agents
directory 'agents'"* and the app exits immediately — the assets live next to the
binary, not in the freshly-relocated cwd.
> **Note (App Translocation).** An unsigned, quarantined `.app` launched from
> `~/Downloads` or a mounted `.dmg` is run from an ephemeral read-only path by
> Gatekeeper, so the symlink targets change per launch (they are recreated each
> time, so this is harmless). Moving the app to `/Applications` clears the
> quarantine and stabilises the paths.
---
## `restart` tool behaviour
`src/core/tools/restart.rs` branches on the feature flag:
| Mode | Behaviour |
| --- | --- |
| Headless | `libc::_exit(-1)` (= exit code 255). `run.sh` sees 255, runs `cargo build`, relaunches. **Rebuilds** the binary — used after the agent edits source code. |
| Desktop | `AppHandle` is fetched from `desktop::app_handle()` (a `OnceLock` populated in the setup hook); then `handle.cleanup_before_exit()` + `std::process::Command::new(current_exe).spawn()` + `std::process::exit(0)`. **No rebuild** — the bundled binary is read-only. Useful for picking up `config.yml` / DB changes that are only read at startup. |
See also [self-rewriting.md](self-rewriting.md).
---
## Icons
Two families live under `icons/`:
| Family | Files | Purpose |
| --- | --- | --- |
| App icon (colour) | `icon.png` (1024×1024 source), `32x32.png`, `128x128.png`, `128x128@2x.png`, `icon.icns` (macOS), `icon.ico` (Windows) | Window icon, bundle icon. |
| Tray template (monochrome) | `tray-template.png` (32×32, black on transparent) | macOS menu-bar template image (auto-recoloured by the system for dark/light). On Windows/Linux a coloured icon would be more visible — currently `default_window_icon()` is used as a fallback everywhere until a Tauri 2.x image-loading API is wired in. |
The current subject is a stylised amber feather on a dark rounded square
(Skald = Norse *bard*). To regenerate from a new design, edit
`/tmp/gen_all_icons.py` (Pillow) and re-run `iconutil` for `.icns` and `magick`
for `.ico`. Sources live with the icons; the script is not yet committed.
---
## Known limitations / next steps
* **`LSUIElement` (menu-bar-only on macOS).** Removed from `tauri.conf.json`
because Tauri v2 dropped the `bundle.macOS.infoPlist` key (v1) — the v2 way
is a custom `Info.plist` file under `src-tauri/`. Until the crate layout
grows a `src-tauri/` directory, the app shows both a Dock icon and a tray
icon. Add the `Info.plist` to make it menu-bar-only.
* **Tray template icon.** The bundled `tray-template.png` is not yet wired up
(Tauri 2.11.5's `Image::from_path` / `from_bytes` API surface differs from
later versions). The tray currently reuses `app.default_window_icon()`, which
is the colour app icon — visible on macOS but not template-styled.
* **Bundled assets — done.** `agents/`, `web/`, `skills/`, `commands/` are
packaged via `bundle.resources` and symlinked into the data dir by
`link_bundled_assets()` (see *Read-only bundled assets* above). Remaining
polish: user-agent overrides beyond the symlink escape-hatch, and refreshing
the copy-on-write story for signed/notarized distribution.
* **Windows/Linux bundle.** `running_from_bundle()` currently only detects
`.app/` on macOS; Windows (`Program Files`) and Linux
(`/usr/share`/`/opt`) detection is TBD.
* **CI matrix.** No GitHub Actions workflow yet for cross-platform bundle
builds.
---
## When to update this file
* The `desktop` feature or its dependencies change.
* The tray menu, window policy, or shutdown path change.
* The data-directory relocation heuristic changes (new platform, new path).
* The `restart` tool's desktop-mode behaviour changes.
* The packaging story is completed (`bundle.resources`, `Info.plist`, CI).