211 lines
8.2 KiB
Rust
211 lines
8.2 KiB
Rust
mod boot;
|
|
mod core;
|
|
#[cfg(feature = "desktop")]
|
|
mod desktop;
|
|
mod frontend;
|
|
mod config;
|
|
|
|
use std::io::IsTerminal;
|
|
use std::sync::Arc;
|
|
|
|
use anyhow::{Context, Result};
|
|
use sqlx::SqlitePool;
|
|
use tracing::level_filters::LevelFilter;
|
|
use tracing::{debug, error, info, warn};
|
|
use tracing_subscriber::filter::Targets;
|
|
use tracing_subscriber::layer::SubscriberExt;
|
|
use tracing_subscriber::util::SubscriberInitExt;
|
|
use tracing_subscriber::Layer;
|
|
|
|
use core_api::plugin::Plugin;
|
|
use config::Config;
|
|
use crate::core::db::{SYSTEM_DB_PATH, init_pool};
|
|
use crate::core::skald::Skald;
|
|
use crate::frontend::WebFrontend;
|
|
use crate::frontend::server::WebServerHandle;
|
|
|
|
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.
|
|
pub struct Backend {
|
|
pub skald: Arc<Skald>,
|
|
pub web: WebServerHandle,
|
|
pub pool: Arc<SqlitePool>,
|
|
}
|
|
|
|
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.
|
|
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())
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
fn init_logging() {
|
|
let log_dir = config::resolved_log_dir();
|
|
std::fs::create_dir_all(&log_dir).ok();
|
|
let file_appender = tracing_appender::rolling::daily(&log_dir, format!("{APP_NAME}.log"));
|
|
let (non_blocking, _log_guard) = tracing_appender::non_blocking(file_appender);
|
|
// The worker thread behind `non_blocking` must outlive any shutdown path;
|
|
// intentionally leak the guard so the writer is never dropped mid-process.
|
|
// Logs are flushed by the rolling appender's own background thread.
|
|
std::mem::forget(_log_guard);
|
|
|
|
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
|
|
|
|
// File layer: full structured logs, governed by RUST_LOG.
|
|
let file_layer = tracing_subscriber::fmt::layer()
|
|
.with_writer(non_blocking)
|
|
.with_ansi(false)
|
|
.with_filter(env_filter);
|
|
|
|
// Stdout layer: only the curated `boot` target, rendered cleanly. Its own
|
|
// target filter makes it independent of RUST_LOG, so bootstrap always shows.
|
|
// ANSI is enabled only on a real terminal.
|
|
let boot_layer = tracing_subscriber::fmt::layer()
|
|
.event_format(boot::BootFormat)
|
|
.with_writer(std::io::stdout)
|
|
.with_ansi(std::io::stdout().is_terminal())
|
|
.with_filter(Targets::new().with_target(boot::TARGET, LevelFilter::TRACE));
|
|
|
|
tracing_subscriber::registry()
|
|
.with(file_layer)
|
|
.with(boot_layer)
|
|
.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.
|
|
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());
|
|
let msg = info.payload().downcast_ref::<&str>().map(|s| (*s).to_string())
|
|
.or_else(|| info.payload().downcast_ref::<String>().cloned())
|
|
.unwrap_or_else(|| "<non-string panic payload>".to_string());
|
|
error!(target: "panic", location = %location, message = %msg, "thread panicked");
|
|
default_panic(info);
|
|
}));
|
|
}
|
|
|
|
/// Headless entry point (no Tauri): run the backend, wait for a shutdown
|
|
/// signal, then shut everything down. Used only in `cfg(not(feature = "desktop"))`.
|
|
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")));
|
|
|
|
let backend = run_backend().await?;
|
|
|
|
let signal = wait_for_shutdown_signal().await;
|
|
warn!(signal, "shutdown signal received — shutting down");
|
|
|
|
shutdown_backend(backend).await;
|
|
info!("shutdown complete");
|
|
Ok(())
|
|
}
|
|
|
|
/// 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); }
|
|
};
|
|
let (core_cfg, frontend_cfg) = cfg.into_split();
|
|
|
|
let plugins = build_plugins();
|
|
|
|
let pool = Arc::new(init_pool(SYSTEM_DB_PATH).await?);
|
|
info!(path = SYSTEM_DB_PATH, "database ready");
|
|
|
|
let skald = Skald::new(Arc::clone(&pool), &core_cfg, plugins).await?;
|
|
|
|
let handle = WebFrontend::new(skald.clone(), Arc::clone(&pool), &frontend_cfg)
|
|
.start().await?;
|
|
|
|
Ok(Backend { skald, web: handle, pool })
|
|
}
|
|
|
|
/// Build the plugin list. Extracted so both entry points share the same set.
|
|
fn build_plugins() -> Vec<Arc<dyn Plugin>> {
|
|
let mut plugins: Vec<Arc<dyn Plugin>> = vec![
|
|
Arc::new(plugin_honcho::HonchoPlugin::new()),
|
|
Arc::new(plugin_telegram_bot::TelegramPlugin::new("secrets")),
|
|
Arc::new(plugin_tailscale_remote::RemotePlugin::new()),
|
|
Arc::new(plugin_comfyui::ComfyUIPlugin::new()),
|
|
Arc::new(plugin_tts_orpheus_3b::OrpheusTtsPlugin::new()),
|
|
Arc::new(plugin_tts_kokoro::KokoroTtsPlugin::new()),
|
|
Arc::new(plugin_elevenlabs::ElevenLabsPlugin::new()),
|
|
Arc::new(plugin_mobile_connector::MobileConnectorPlugin::new()),
|
|
];
|
|
#[cfg(feature = "whisper-local")]
|
|
plugins.push(Arc::new(plugin_transcribe_whisper_local::WhisperLocalPlugin::new()));
|
|
plugins
|
|
}
|
|
|
|
/// Graceful shutdown of the backend: HTTP server, Skald managers, DB pool.
|
|
/// Order matters: web first (stop accepting requests), then skald (cancel
|
|
/// background tasks), then DB pool.
|
|
pub async fn shutdown_backend(backend: Backend) {
|
|
backend.web.shutdown().await;
|
|
backend.skald.shutdown().await;
|
|
backend.pool.close().await;
|
|
}
|
|
|
|
/// Wait for an OS shutdown signal and return its name for logging.
|
|
///
|
|
/// We trap **both** SIGINT (Ctrl+C) and SIGTERM. Without an explicit SIGTERM
|
|
/// handler the default action kills the process with exit code 143, which the
|
|
/// `run.sh` supervisor treats as a hard stop (only exit 255 triggers a
|
|
/// restart) — and the kill leaves no trace in the log. Trapping it lets us log
|
|
/// the cause and shut down gracefully (exit 0).
|
|
#[cfg(unix)]
|
|
async fn wait_for_shutdown_signal() -> &'static str {
|
|
use tokio::signal::unix::{signal, SignalKind};
|
|
let mut sigterm = signal(SignalKind::terminate()).expect("install SIGTERM handler");
|
|
let mut sigint = signal(SignalKind::interrupt()).expect("install SIGINT handler");
|
|
tokio::select! {
|
|
_ = sigterm.recv() => "SIGTERM",
|
|
_ = sigint.recv() => "SIGINT",
|
|
}
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
async fn wait_for_shutdown_signal() -> &'static str {
|
|
let _ = tokio::signal::ctrl_c().await;
|
|
"CTRL_C"
|
|
}
|