15 KiB
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)
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)
cargo tauri build --features desktop
This requires the tauri-cli:
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-localfeature compileswhisper.cpp(C++), whoseggml-backend-reg.cppusesstd::filesystem::path— introduced in macOS 10.15.cargo tauri buildinjectsMACOSX_DEPLOYMENT_TARGETfromtauri.conf.json > bundle.macOS.minimumSystemVersion(Tauri's default is"10.13"), on whichstd::filesystem::pathis marked unavailable, so the ggml build fails with ~20'path' is unavailableerrors.The fix has three coordinated pieces, all pinned to
"10.15"(the exact floor that unblocksstd::filesystem):
.cargo/config.tomlsets two forced env vars. Both are needed because the C++ compile otherwise ends up with two conflicting-mmacosx-version-minflags and clang lets the last one win:
MACOSX_DEPLOYMENT_TARGET→ thecccrate turns this into the CFLAGS'-mmacosx-version-min.CMAKE_OSX_DEPLOYMENT_TARGET→whisper-rs-sys'sbuild.rsforwards anyCMAKE_*env var to cmake as-DCMAKE_OSX_DEPLOYMENT_TARGET=…, which sets CMake's own-mmacosx-version-minand overrides a value cached in a staleCMakeCache.txt. Without this, CMake's cached 10.13 wins.
force = truemakes 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.
tauri.conf.json > bundle.macOS.minimumSystemVersion = "10.15"— the value baked into the bundle'sInfo.plistasLSMinimumSystemVersion.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-sysdoes not always clear the cmakeout/dir). Piece 1's explicit-Dflag 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 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::CloseRequestedand turned into ahide(). The app keeps running in the tray. - Quit (and Cmd+Q / system shutdown) triggers
RunEvent::ExitRequested. The handler spawnsshutdown_backend()on the async runtime, waits for it to drain the HTTP server / Skald managers / DB pool, then callsapp.exit(0). AnAtomicBoolre-entrancy guard prevents the secondExitRequested(emitted by theapp.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_dirs 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
.applaunched from~/Downloadsor a mounted.dmgis 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/Applicationsclears 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.
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 fromtauri.conf.jsonbecause Tauri v2 dropped thebundle.macOS.infoPlistkey (v1) — the v2 way is a customInfo.plistfile undersrc-tauri/. Until the crate layout grows asrc-tauri/directory, the app shows both a Dock icon and a tray icon. Add theInfo.plistto make it menu-bar-only.- Tray template icon. The bundled
tray-template.pngis not yet wired up (Tauri 2.11.5'sImage::from_path/from_bytesAPI surface differs from later versions). The tray currently reusesapp.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 viabundle.resourcesand symlinked into the data dir bylink_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
desktopfeature or its dependencies change. - The tray menu, window policy, or shutdown path change.
- The data-directory relocation heuristic changes (new platform, new path).
- The
restarttool's desktop-mode behaviour changes. - The packaging story is completed (
bundle.resources,Info.plist, CI).