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
+96
View File
@@ -5,6 +5,11 @@
//! user can override it on their own profile (`users.locale`); the frontend
//! resolves user → instance → built-in English at boot.
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use core_api::i18n::{I18nApi, LocaleBundle};
use core_api::{ConfigProperty, ConfigSet, PropertyType};
pub const DEFAULT_LOCALE_KEY: &str = "ui_locale";
@@ -88,6 +93,70 @@ pub async fn set_default_locale(pool: &sqlx::SqlitePool, locale: &str) -> anyhow
Ok(())
}
/// The backend translation catalog — the concrete [`I18nApi`] injected into
/// every `PluginContext`. Built once at boot by merging every plugin's
/// [`core_api::plugin::Plugin::i18n`] bundles, keyed by locale. Lookups follow
/// the same chain as the frontend `t()`: resolved locale → English → the raw
/// key, with `{name}` placeholders filled from `args`.
///
/// Immutable after construction: bundles are collected before any request, so
/// no lock is needed on the read path (`get` is a plain map lookup).
pub struct I18nCatalog {
/// System pool — reads `users.locale` and the instance-default `config` key
/// to resolve a user's effective locale (see [`resolve_locale`]).
pool: Arc<sqlx::SqlitePool>,
/// locale → (key → string).
tables: HashMap<String, HashMap<String, String>>,
}
impl I18nCatalog {
/// Merge `bundles` into one catalog. Two bundles for the same locale union
/// their keys (later wins on a collision — the `plugin.<id>.` convention
/// keeps collisions to genuine overrides).
pub fn new(pool: Arc<sqlx::SqlitePool>, bundles: Vec<LocaleBundle>) -> Self {
let mut tables: HashMap<String, HashMap<String, String>> = HashMap::new();
for b in bundles {
tables.entry(b.locale).or_default().extend(b.strings);
}
Self { pool, tables }
}
fn lookup(&self, locale: &str, key: &str) -> Option<&str> {
self.tables.get(locale).and_then(|m| m.get(key)).map(String::as_str)
}
/// Resolve → fall back to English → fall back to the key itself, then fill
/// `{name}` placeholders.
fn render(&self, locale: &str, key: &str, args: &[(&str, &str)]) -> String {
let raw = self
.lookup(locale, key)
.or_else(|| self.lookup("en", key))
.unwrap_or(key);
let mut s = raw.to_string();
for (k, v) in args {
s = s.replace(&format!("{{{k}}}"), v);
}
s
}
}
#[async_trait]
impl I18nApi for I18nCatalog {
async fn for_user(&self, user_id: &str, key: &str, args: &[(&str, &str)]) -> String {
let user_locale = crate::db::users::get(&self.pool, user_id)
.await
.ok()
.flatten()
.and_then(|u| u.locale);
let locale = resolve_locale(&self.pool, user_locale.as_deref()).await;
self.render(&locale, key, args)
}
fn get(&self, locale: &str, key: &str, args: &[(&str, &str)]) -> String {
self.render(locale, key, args)
}
}
pub fn config_set() -> ConfigSet {
ConfigSet {
name: "Interface".into(),
@@ -154,4 +223,31 @@ mod tests {
pool.close().await;
cleanup(&path);
}
#[tokio::test]
async fn catalog_renders_with_fallback_and_interpolation() {
let path = temp_db_path("catalog");
let pool = Arc::new(crate::db::init_system_pool(&path).await.unwrap());
let bundle = |loc: &str, pairs: &[(&str, &str)]| LocaleBundle::new(
loc,
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
);
let cat = I18nCatalog::new(Arc::clone(&pool), vec![
bundle("en", &[("p.hi", "Hi {name}"), ("p.only_en", "Only EN")]),
bundle("it", &[("p.hi", "Ciao {name}")]),
]);
// Exact locale hit + placeholder fill.
assert_eq!(cat.get("it", "p.hi", &[("name", "Ada")]), "Ciao Ada");
// Missing key in locale → English fallback.
assert_eq!(cat.get("it", "p.only_en", &[]), "Only EN");
// Missing everywhere → the raw key.
assert_eq!(cat.get("it", "p.absent", &[]), "p.absent");
// Unknown locale → English fallback.
assert_eq!(cat.get("de", "p.hi", &[("name", "Bo")]), "Hi Bo");
pool.close().await;
cleanup(&path);
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
//! The headless Skald core: storage, identity, LLM stack, tools, sessions.
//!
//! Nothing here knows what runs it. The process shell — HTTP server, desktop
//! webview, setup wizard — lives in the crates that depend on this one. Concrete
//! Nothing here knows what runs it. The process shell — HTTP server, setup
//! wizard — lives in the crates that depend on this one. Concrete
//! plugins are never named: `plugin::PluginManager` only ever sees
//! `Arc<dyn Plugin>`, constructed by the consumer and handed to `Skald::new`.
+20
View File
@@ -105,6 +105,10 @@ pub struct PluginManager {
router_factory: OnceLock<RouterFactory>,
/// HTTP port the web server is bound to — provided by WebFrontend before start_enabled().
web_port: OnceLock<u16>,
/// Backend i18n catalog, built once from every plugin's `Plugin::i18n()` on
/// first context build (all plugins are registered by then). Injected into
/// every `PluginContext` so a plugin can localize its own backend strings.
i18n: OnceLock<Arc<crate::i18n::I18nCatalog>>,
/// Last known (enabled, config_json) per plugin id — used by the watcher.
known_state: Mutex<HashMap<String, (bool, String)>>,
}
@@ -118,6 +122,7 @@ impl PluginManager {
skald: OnceLock::new(),
router_factory: OnceLock::new(),
web_port: OnceLock::new(),
i18n: OnceLock::new(),
known_state: Mutex::new(HashMap::new()),
}
}
@@ -149,6 +154,20 @@ impl PluginManager {
.ok_or_else(|| anyhow::anyhow!("PluginManager: skald not initialized"))
}
/// The shared backend i18n catalog, built once by merging every registered
/// plugin's `Plugin::i18n()` bundles. All plugins are registered before the
/// first `build_context`, so a single lazy build is correct.
fn i18n(&self) -> Arc<dyn core_api::i18n::I18nApi> {
let catalog = self.i18n.get_or_init(|| {
let mut bundles = Vec::new();
for plugin in &self.plugins {
bundles.extend(plugin.i18n());
}
Arc::new(crate::i18n::I18nCatalog::new(Arc::clone(&self.db), bundles))
});
Arc::clone(catalog) as Arc<dyn core_api::i18n::I18nApi>
}
fn build_context(&self, skald: &Skald) -> Result<PluginContext> {
let router_factory = self.router_factory.get().cloned()
.ok_or_else(|| anyhow::anyhow!("PluginManager: router_factory not set"))?;
@@ -170,6 +189,7 @@ impl PluginManager {
system_bus: Arc::clone(skald.system_bus()),
user_channel: self.skald()? as Arc<dyn core_api::user_channel::UserChannelApi>,
user_config: Arc::clone(&self.user_config) as _,
i18n: self.i18n(),
web_port,
remote_slot: Arc::clone(skald.remote()),
router_factory,
+2 -1
View File
@@ -113,7 +113,8 @@ impl Media {
).await?;
// Evaluate the await outside the `info!` macro: leaving the temporary
// `tracing::Value` from the field expression alive across the await
// makes the surrounding future non-Send, which Tauri's runtime rejects.
// makes the surrounding future non-Send, which the multi-threaded
// runtime rejects.
let image_generator_models = image_generator_manager.list_models_info().await.len();
info!(
db_backed = image_generator_models,
+7 -6
View File
@@ -8,10 +8,11 @@ use crate::tools::{Tool, ToolDescriptionLength};
/// How to restart, when exiting for a supervisor is not the answer.
///
/// A bundled desktop app has no supervisor watching its exit code: it must tear
/// down its own webview and respawn itself. That is knowledge about the process
/// shell, and the core does not have it — so the shell installs it here. Without
/// a handler, `restart` falls back to the supervisor protocol.
/// A shell with no supervisor watching its exit code would need to tear itself
/// down and respawn on its own. That is knowledge about the process shell, which
/// the core does not have — so such a shell installs it here. The default server
/// shell has a supervisor (`run.sh`) and installs no handler, so `restart` falls
/// back to the supervisor protocol below.
///
/// Returns only on failure; a successful handler never comes back.
pub type RestartHandler = Box<dyn Fn() -> Result<()> + Send + Sync>;
@@ -50,8 +51,8 @@ impl Tool for Restart {
}
fn execute(&self, _args: Value) -> Result<String> {
// A bundled desktop app installs its own teardown-and-respawn. Normally
// this never returns.
// A shell that installed its own teardown-and-respawn handles it here.
// Normally this never returns.
if let Some(handler) = HANDLER.get() {
info!("restart requested — delegating to the installed handler");
handler()?;