Refactor: remove desktop/Tauri bundle, add i18n, CI/CD pipeline
Nightly Build / build (push) Failing after 6s
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
Reference in New Issue
Block a user