Refactor: remove desktop/Tauri bundle, add i18n, CI/CD pipeline
Nightly Build / build (push) Failing after 6s

- Remove desktop (Tauri) bundle: docs/desktop.md, icons/, tauri.conf.json,
  src/desktop/mod.rs, gen/schemas/
- Remove build.rs (no longer needed)
- Add i18n system (crates/core-api, plugin-mobile-connector, web)
- Refactor config system (src/config.rs, boot_format.rs)
- Add mobile connector features (app, router, device pairing)
- Plugin system improvements (skald-core)
- Update dependencies (Cargo.lock, Cargo.toml)
- CI/CD: Gitea Actions workflows (nightly + release), package.sh,
  verify-version.sh, builds.skaldagent.net config
This commit is contained in:
2026-07-19 22:35:06 +01:00
parent ba911ae8cb
commit fb3eeeeec6
54 changed files with 823 additions and 8002 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
//! How this binary renders the bootstrap lines that `skald_core::boot` emits.
//!
//! Rendering is the shell's business, not the core's: a desktop bundle or a
//! setup wizard would format the same `boot` target differently, or not at all.
//! Rendering is the shell's business, not the core's: another shell (e.g. the
//! setup wizard) formats the same `boot` target differently, or not at all.
//! Wired in `main.rs` as a stdout layer filtered on `boot::TARGET`, independent
//! of `RUST_LOG`, so this output always appears whatever the log filter is.
+1 -147
View File
@@ -12,15 +12,6 @@ pub use skald_core::config::{
const DEFAULT_CONFIG: &str = "default.config.yaml";
const CONFIG: &str = "config.yml";
/// Default config baked into the binary at compile time.
///
/// Used by [`bootstrap_data_dir`] (desktop mode) to seed `config.yml` on first
/// launch, where the bundled binary cannot rely on `default.config.yaml` being
/// next to it on disk (the cwd has already been relocated to the per-user data
/// dir). Headless mode still copies `default.config.yaml` from the source tree
/// as before.
const DEFAULT_CONFIG_EMBEDDED: &str = include_str!("../default.config.yaml");
#[derive(Debug, Deserialize)]
pub struct Config {
pub server: ServerConfig,
@@ -104,144 +95,7 @@ impl Config {
}
}
/// Absolute directory for log files.
///
/// `init_logging()` runs **before** [`bootstrap_data_dir`] relocates the cwd, so
/// a relative `"logs"` path would resolve against the bundle's launch cwd (`/`
/// for a Finder-launched `.app`) — un-writable, so the log folder stays empty.
/// In a packaged bundle, return an absolute path under the per-user data dir
/// instead. In every other mode (headless, desktop dev) keep the historical
/// relative `"logs"`, unchanged.
/// Directory for log files: a relative `"logs"` under the launch cwd.
pub fn resolved_log_dir() -> std::path::PathBuf {
#[cfg(feature = "desktop")]
{
if running_from_bundle() {
if let Some(dir) = dirs::data_dir() {
return dir.join("Skald").join("logs");
}
}
}
std::path::PathBuf::from("logs")
}
/// In desktop mode (Tauri bundle), relocate the process working directory to
/// the OS-appropriate per-user data dir so that every relative path in
/// `config.yml` (db, logs, data, secrets, models, agents, …) resolves there
/// instead of `/` (the default cwd of a `.app` bundle on macOS, or the Windows
/// equivalent). Also seeds `config.yml` from the bundled default if missing.
///
/// | OS | Location |
/// |---------|-----------------------------------------------------|
/// | macOS | `~/Library/Application Support/Skald` |
/// | Windows | `%APPDATA%\Skald` (= `C:\Users\<u>\AppData\Roaming`)|
/// | Linux | `~/.local/share/Skald` |
///
/// ## When relocation happens
/// Only when the process is running from a packaged bundle (e.g. inside
/// `Skald.app/Contents/MacOS/`). In dev mode (`cargo run --features desktop`),
/// the cwd is left untouched so all source-tree assets (`agents/`, `skills/`,
/// `web/`, `config.yml`, …) keep resolving from the crate root as in headless
/// mode.
///
/// In headless mode this is always a no-op: the cwd stays as the user launched
/// it, preserving today's behaviour (`./database.db`, `./logs/`, …).
#[cfg(feature = "desktop")]
pub fn bootstrap_data_dir() -> Result<()> {
use tracing::info;
if !running_from_bundle() {
info!("desktop mode (dev): cwd unchanged — using source-tree assets");
return Ok(());
}
let data_dir = dirs::data_dir()
.context("could not determine OS data directory")?
.join("Skald");
std::fs::create_dir_all(&data_dir)
.with_context(|| format!("failed to create data dir at {}", data_dir.display()))?;
info!(path = %data_dir.display(), "desktop mode: relocating cwd to per-user data dir");
std::env::set_current_dir(&data_dir)
.with_context(|| format!("failed to cd to {}", data_dir.display()))?;
// Seed config.yml from the bundled default if absent (first launch).
let config_path = Path::new(CONFIG);
if !config_path.exists() {
let _ = std::fs::write(CONFIG, DEFAULT_CONFIG_EMBEDDED);
info!(path = %data_dir.display(), "seeded config.yml from embedded default");
}
// Make the read-only bundled assets reachable from the relocated cwd.
link_bundled_assets(&data_dir)?;
Ok(())
}
/// Read-only asset directories shipped inside the `.app` bundle's `Resources/`
/// dir (see `tauri.conf.json > bundle > resources`). The backend looks these up
/// by relative path from the cwd (agent discovery reads `agents/`, Axum serves
/// `web/`, etc.), but the cwd has just been relocated to the data dir — where
/// they don't exist. Without this, `Skald::new` fails with
/// "Failed to read agents directory 'agents'" and the app exits on launch.
#[cfg(feature = "desktop")]
const BUNDLED_ASSETS: &[&str] = &["agents", "web", "skills", "commands"];
/// (Re)link each bundled asset dir into the per-user data dir as a symlink to
/// the copy inside the app bundle's `Resources/`, so the existing relative-path
/// lookups resolve while mutable state (db, config, logs, secrets) stays in the
/// data dir itself.
///
/// Symlinking (rather than copying) keeps the assets in sync with the installed
/// app version automatically. A pre-existing **real** directory is treated as a
/// user override and left untouched; only symlinks are refreshed.
#[cfg(feature = "desktop")]
fn link_bundled_assets(data_dir: &Path) -> Result<()> {
use tracing::{info, warn};
let exe = std::env::current_exe().context("could not resolve current_exe")?;
// `.../Skald.app/Contents/MacOS/skald` → `.../Skald.app/Contents/Resources`
let resource_dir = match exe.parent().and_then(|p| p.parent()) {
Some(contents) => contents.join("Resources"),
None => {
warn!("could not derive bundle Resources dir from exe path — skipping asset link");
return Ok(());
}
};
for name in BUNDLED_ASSETS {
let src = resource_dir.join(name);
if !src.exists() {
warn!(asset = name, "bundled asset missing from Resources — skipping");
continue;
}
let dst = data_dir.join(name);
match std::fs::symlink_metadata(&dst) {
// Stale symlink from a previous launch — replace it.
Ok(meta) if meta.file_type().is_symlink() => { let _ = std::fs::remove_file(&dst); }
// A real dir/file the user created — respect it, don't clobber.
Ok(_) => continue,
// Absent — fall through and create.
Err(_) => {}
}
#[cfg(unix)]
std::os::unix::fs::symlink(&src, &dst)
.with_context(|| format!("failed to symlink {} -> {}", dst.display(), src.display()))?;
info!(asset = name, target = %src.display(), "linked bundled asset into data dir");
}
Ok(())
}
/// Heuristic: are we running inside a packaged bundle (e.g. `Foo.app`)?
/// Used to decide whether to relocate the cwd to the per-user data dir.
#[cfg(feature = "desktop")]
fn running_from_bundle() -> bool {
#[cfg(target_os = "macos")]
{
if let Ok(exe) = std::env::current_exe() {
return exe.to_string_lossy().contains(".app/");
}
}
// Windows / Linux: TBD when packaging targets land. For now treat all
// launches as dev mode (no cwd relocation).
false
}
#[cfg(not(feature = "desktop"))]
pub fn bootstrap_data_dir() -> Result<()> {
// Headless mode: keep cwd as launched, no relocation.
Ok(())
}
-248
View File
@@ -1,248 +0,0 @@
//! Desktop (Tauri) entry point — compiled only under `--features desktop`.
//!
//! Wraps the headless Skald backend in a Tauri event loop. The backend runs on
//! Tauri's shared tokio runtime (no dual runtime). A system-tray icon provides
//! `Open` (show+focus the main window) and `Quit` (graceful shutdown).
//!
//! ## Window policy
//! The main window starts hidden. The traffic-light red / window X button
//! *hides* it instead of closing — the app keeps running in the tray. Only the
//! tray's `Quit` menu item (or Cmd+Q / system termination) actually shuts the
//! backend down and exits.
//!
//! ## Restart safety
//! The `tauri::RunEvent::ExitRequested` handler is re-entrant-guarded by an
//! `AtomicBool`: the first trigger prevents the exit, runs the async backend
//! shutdown, then calls `app.exit(0)` (which would otherwise loop).
//!
//! See `docs/desktop.md` for the architecture overview and build instructions.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use tauri::{
menu::{MenuBuilder, MenuItemBuilder},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
Manager, RunEvent, WebviewUrl, WebviewWindowBuilder, WindowEvent,
};
use tracing::{error, info, warn};
use crate::{config::Config, run_backend, shutdown_backend, Backend};
/// Slot for the backend handle, kept in Tauri's managed state.
///
/// `None` until the async `run_backend()` completes; `Some(Backend)` afterwards.
/// The exit handler takes ownership when the user quits, so we need an
/// `Option` rather than a plain `Backend`.
type BackendSlot = Mutex<Option<Backend>>;
/// Process-wide handle to the Tauri app, populated once in the setup hook.
///
/// Used by code paths that don't naturally receive an `AppHandle` (notably the
/// `restart` tool, which is constructed deep inside the tool registry but needs
/// to trigger `AppHandle::restart()` in desktop mode).
static APP_HANDLE: OnceLock<tauri::AppHandle> = OnceLock::new();
/// Take a clone of the Tauri `AppHandle`, if the desktop runtime is up.
/// Always `None` in headless mode (or before the setup hook has run).
pub fn app_handle() -> Option<tauri::AppHandle> {
APP_HANDLE.get().cloned()
}
/// Desktop entry point. Builds the Tauri app, spawns the backend on its
/// shared tokio runtime, wires the system-tray menu, and runs the event loop.
pub fn run() -> anyhow::Result<()> {
info!(version = env!("CARGO_PKG_VERSION"), "starting skald (desktop mode)");
// Re-entrancy guard: the first `ExitRequested` triggers async shutdown and
// then calls `app.exit(0)`, which would itself re-emit `ExitRequested`. The
// flag short-circuits the second trigger so we actually leave the process.
let exiting = Arc::new(AtomicBool::new(false));
tauri::Builder::default()
// Pre-register the backend slot so it exists before the setup hook
// (the setup hook spawns the backend async; state must already be there).
.manage::<BackendSlot>(Mutex::new(None))
.setup(|app| {
// Stash the app handle for code paths without a natural handle
// reference (notably the `restart` tool, reached through the handler
// installed just below).
let _ = APP_HANDLE.set(app.handle().clone());
// Teach the core how to restart *this* shell. A bundled app has no
// supervisor reading its exit code, and the binary is read-only, so
// "restart" means: tear down Tauri, spawn a fresh copy, exit. This
// mirrors what `tauri-plugin-process`'s JS `restart` does internally.
skald_core::tools::restart::set_restart_handler(Box::new(|| {
let handle = app_handle()
.ok_or_else(|| anyhow::anyhow!("Tauri app handle is not set yet"))?;
let exe = std::env::current_exe()
.map_err(|e| anyhow::anyhow!("failed to resolve current_exe: {e}"))?;
// Tauri-side teardown (webview, tray, event loop, windows).
handle.cleanup_before_exit();
// Spawn a fresh copy of the current binary (detached).
let _ = std::process::Command::new(exe).spawn();
std::process::exit(0);
}));
build_tray(app)?;
// Resolve the backend port from config so the webview URL is always
// in sync with where Axum will actually bind. We load the config
// sync here just to read the port; the backend task re-loads it
// (cheap — single YAML parse).
// In desktop mode this also performs the cwd relocation (no-op in
// dev, real relocation inside an `.app` bundle).
let port = match std::panic::catch_unwind(|| {
crate::config::bootstrap_data_dir()
.and_then(|_| Config::load())
.map(|c| c.server.port)
}) {
Ok(Ok(port)) => port,
Ok(Err(e)) => {
error!(error = %e, "failed to load config for window URL");
app.handle().exit(1);
return Ok(());
}
Err(_) => {
error!("config load panicked");
app.handle().exit(1);
return Ok(());
}
};
let url = format!("http://127.0.0.1:{port}");
info!(%url, "creating main window");
let parsed_url = tauri::Url::parse(&url)
.map_err(tauri::Error::InvalidUrl)?;
WebviewWindowBuilder::new(app, "main", WebviewUrl::External(parsed_url))
.title("Skald")
.inner_size(1200.0, 800.0)
.min_inner_size(800.0, 600.0)
.visible(false)
.build()?;
let app_handle = app.handle().clone();
// Spawn the backend on Tauri's shared tokio runtime.
tauri::async_runtime::spawn(async move {
match run_backend().await {
Ok(backend) => {
info!("backend ready — desktop mode");
let slot = app_handle.state::<BackendSlot>();
*slot.lock().unwrap() = Some(backend);
// Reveal the main window now that the backend is serving.
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
Err(e) => {
error!(error = %e, "backend startup failed");
app_handle.exit(1);
}
}
});
Ok(())
})
// Close button (traffic-light red / X) → hide instead of close.
// The window stays alive in the tray; only "Quit" terminates.
.on_window_event(|window, event| {
if let WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = window.hide();
}
})
.build(tauri::generate_context!())?
.run({
let exiting = exiting.clone();
move |app_handle, event| {
if let RunEvent::ExitRequested { api, .. } = event {
// Second trigger (from our own app.exit(0)) — let it proceed.
if exiting.swap(true, Ordering::SeqCst) {
return;
}
api.prevent_exit();
let app_handle = app_handle.clone();
tauri::async_runtime::spawn(async move {
// Scope the MutexGuard so it is dropped before any `.await`:
// std::sync::MutexGuard is !Send, so holding it across an
// await point would make the whole future !Send (Tauri's
// runtime requires Send futures).
let backend = {
let slot = app_handle.state::<BackendSlot>();
slot.lock().unwrap().take()
};
if let Some(backend) = backend {
info!("graceful shutdown — desktop mode");
shutdown_backend(backend).await;
info!("shutdown complete — desktop mode");
} else {
warn!("exit requested before backend was ready");
}
// Actually leave the process now.
app_handle.exit(0);
});
}
}
});
Ok(())
}
/// Build the system-tray icon, its menu (Open / Quit), and the event handlers.
fn build_tray(app: &tauri::App) -> tauri::Result<()> {
let open = MenuItemBuilder::with_id("open", "Open").build(app)?;
let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
let menu = MenuBuilder::new(app).items(&[&open, &quit]).build()?;
// Tray icon: reuse the app's bundled window icon for now. On macOS the
// system auto-recolors template images for the menubar theme; we set
// `icon_as_template(true)` accordingly. A dedicated monochrome tray PNG
// (loaded via the right Tauri image API for this version) can replace this
// later — see the icon sources under `icons/`.
let icon = app.default_window_icon().cloned()
.ok_or_else(|| tauri::Error::AssetNotFound("default window icon".into()))?;
TrayIconBuilder::with_id("main")
.tooltip("Skald")
.icon(icon)
.icon_as_template(true)
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id().as_ref() {
"open" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
"quit" => {
// Trigger the graceful path via ExitRequested. The handler in
// `run()` will drain the backend and then call `exit(0)`.
app.exit(0);
}
_ => (),
})
.on_tray_icon_event(|tray, event| {
// Single left-click toggles the main window (show+focus or hide).
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) {
let _ = window.hide();
} else {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
}
})
.build(app)?;
Ok(())
}
+1 -1
View File
@@ -48,7 +48,7 @@ use super::ApiError;
static FEED_URL: std::sync::OnceLock<String> = std::sync::OnceLock::new();
/// Installs the feed URL from config. Called once during frontend construction;
/// later calls are ignored, so tests and the desktop shell cannot race it.
/// later calls are ignored, so concurrent callers (e.g. tests) cannot race it.
pub fn set_feed_url(url: String) {
let _ = FEED_URL.set(url.trim_end_matches('/').to_string());
}
+14 -33
View File
@@ -1,6 +1,4 @@
mod boot_format;
#[cfg(feature = "desktop")]
mod desktop;
mod frontend;
mod config;
@@ -11,7 +9,7 @@ use skald_core::boot;
use std::io::IsTerminal;
use std::sync::Arc;
use anyhow::{Context, Result};
use anyhow::Result;
use sqlx::SqlitePool;
use tracing::level_filters::LevelFilter;
use tracing::{debug, error, info, warn};
@@ -31,9 +29,8 @@ const APP_NAME: &str = env!("CARGO_PKG_NAME");
/// Backend handle — everything that must live until shutdown.
///
/// Constructed by [`run_backend`], consumed by [`shutdown_backend`]. In
/// headless mode it lives in `async_main()`; in desktop mode it's stashed in
/// Tauri's managed state (`app.manage(backend)`) and consumed on Quit.
/// Constructed by [`run_backend`], consumed by [`shutdown_backend`]; it lives in
/// `async_main()` for the lifetime of the process.
pub struct Backend {
pub skald: Arc<Skald>,
pub web: WebServerHandle,
@@ -44,31 +41,22 @@ fn main() -> Result<()> {
// Install the rustls crypto provider (ring) before any TLS handshake.
// Required because reqwest is built with `rustls-no-provider` (see
// Cargo.toml): exactly one process-wide provider must be installed before
// the first Client is built. In headless mode this happened to work
// because the first HTTPS request was lazy; in desktop mode the backend
// task fires requests earlier, so install it explicitly up front.
// the first Client is built.
rustls::crypto::ring::default_provider().install_default()
.expect("failed to install rustls ring crypto provider");
init_logging();
#[cfg(feature = "desktop")]
{
desktop::run()
}
#[cfg(not(feature = "desktop"))]
{
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
rt.block_on(async_main())
}
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
rt.block_on(async_main())
}
/// Initialise tracing (file + boot stdout layers) and the panic hook.
///
/// Called once at process start, before either the tokio runtime (headless) or
/// the Tauri event loop (desktop). Not dependent on any async runtime.
/// Called once at process start, before the tokio runtime is built. Not
/// dependent on any async runtime.
fn init_logging() {
let log_dir = config::resolved_log_dir();
std::fs::create_dir_all(&log_dir).ok();
@@ -103,8 +91,8 @@ fn init_logging() {
.init();
// Route panics through tracing so they land in logs/ (the default hook only
// writes to stderr, invisible under supervisors / Tauri). Chain to the
// default hook so the human-readable message + backtrace still print.
// writes to stderr, invisible under a supervisor). Chain to the default hook
// so the human-readable message + backtrace still print.
let default_panic = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let location = info.location().map(|l| l.to_string()).unwrap_or_else(|| "unknown".into());
@@ -116,8 +104,8 @@ fn init_logging() {
}));
}
/// Headless entry point (no Tauri): run the backend, wait for a shutdown
/// signal, then shut everything down. Used only in `cfg(not(feature = "desktop"))`.
/// Entry point: run the backend, wait for a shutdown signal, then shut
/// everything down.
async fn async_main() -> Result<()> {
info!(version = env!("CARGO_PKG_VERSION"), "starting {APP_NAME}");
boot::title(format!("{APP_NAME} v{} — starting", env!("CARGO_PKG_VERSION")));
@@ -135,14 +123,7 @@ async fn async_main() -> Result<()> {
/// Boot the Skald backend: load config, build plugins, open the DB pool,
/// construct `Skald`, and start the web frontend. Returns a [`Backend`] whose
/// components must be shut down via [`shutdown_backend`] for graceful exit.
///
/// Shared by both the headless entry point and the desktop (Tauri) setup hook.
pub async fn run_backend() -> Result<Backend> {
// In desktop mode, relocate the process cwd to the OS-appropriate per-user
// data dir before reading any relative path (db, logs, data, …). Headless
// mode keeps the cwd unchanged.
config::bootstrap_data_dir()?;
let cfg = match Config::load() {
Ok(c) => { debug!("config loaded"); c }
Err(e) => { error!(error = %e, "failed to load config"); return Err(e); }