Files
2026-07-10 15:02:09 +01:00

15 KiB
Raw Permalink Blame History

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-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_TARGETwhisper-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 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_eventWindowEvent::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_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 .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.


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).