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:
@@ -24,6 +24,7 @@ use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use core_api::config_api::ConfigApi;
|
||||
use core_api::i18n::I18nApi;
|
||||
use core_api::user_channel::UserChannelApi;
|
||||
use skald_relay_client::{ClientState, RelayClient, RelayEvent};
|
||||
|
||||
@@ -39,6 +40,9 @@ pub struct RelayApp {
|
||||
pub(crate) user_channel: Arc<dyn UserChannelApi>,
|
||||
/// Config store — used to persist binding removals (logout/revoke).
|
||||
config: Arc<dyn ConfigApi>,
|
||||
/// Backend localization — turns a namespaced key into text in the caller's
|
||||
/// language for the router's error/response strings.
|
||||
i18n: Arc<dyn I18nApi>,
|
||||
/// Device→user bindings, cached in memory; kept in sync by `auth::config_listener`.
|
||||
pub(crate) bindings: RwLock<MobileConfig>,
|
||||
/// When true, a freshly paired device stays Pending until an admin binds it
|
||||
@@ -60,10 +64,12 @@ pub struct RelayApp {
|
||||
}
|
||||
|
||||
impl RelayApp {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
client: Arc<RelayClient>,
|
||||
user_channel: Arc<dyn UserChannelApi>,
|
||||
config: Arc<dyn ConfigApi>,
|
||||
i18n: Arc<dyn I18nApi>,
|
||||
bindings: MobileConfig,
|
||||
require_device_confirmation: bool,
|
||||
notify_delay: Duration,
|
||||
@@ -73,6 +79,7 @@ impl RelayApp {
|
||||
client,
|
||||
user_channel,
|
||||
config,
|
||||
i18n,
|
||||
bindings: RwLock::new(bindings),
|
||||
require_device_confirmation,
|
||||
notify_delay,
|
||||
@@ -100,6 +107,12 @@ impl RelayApp {
|
||||
&self.client
|
||||
}
|
||||
|
||||
/// Backend localizer — the router resolves its error strings to the caller's
|
||||
/// language through this (`app.i18n().for_user(user_id, key, &[])`).
|
||||
pub(crate) fn i18n(&self) -> &Arc<dyn I18nApi> {
|
||||
&self.i18n
|
||||
}
|
||||
|
||||
/// Cancellation token for this run's spawned tasks.
|
||||
pub(crate) fn cancel(&self) -> CancellationToken {
|
||||
self.cancel.clone()
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
//! Backend translation bundles for the mobile-connector.
|
||||
//!
|
||||
//! These are the plugin's **backend** strings — the error/response text its
|
||||
//! router returns, resolved to the caller's language via `PluginContext.i18n`
|
||||
//! (see `core_api::i18n`). The frontend fragment's UI strings live separately in
|
||||
//! `web/i18n.js` (registered client-side); the two sets barely overlap, so each
|
||||
//! side owns its own table rather than sharing one over an endpoint.
|
||||
//!
|
||||
//! The tables ship as JSON embedded at compile time — one file per locale, keys
|
||||
//! namespaced `plugin.mobile-connector.*`. A malformed file is skipped (its
|
||||
//! locale simply falls back to English) rather than failing the build path.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use core_api::i18n::LocaleBundle;
|
||||
|
||||
/// Every locale bundle this plugin contributes, parsed from the embedded JSON.
|
||||
pub fn bundles() -> Vec<LocaleBundle> {
|
||||
[
|
||||
("en", include_str!("../i18n/en.json")),
|
||||
("it", include_str!("../i18n/it.json")),
|
||||
("fr", include_str!("../i18n/fr.json")),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(locale, raw)| {
|
||||
match serde_json::from_str::<HashMap<String, String>>(raw) {
|
||||
Ok(strings) => Some(LocaleBundle::new(locale, strings)),
|
||||
Err(e) => {
|
||||
tracing::warn!(locale, error = %e, "mobile-connector i18n bundle failed to parse");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -31,6 +31,7 @@ mod agent;
|
||||
mod app;
|
||||
mod auth;
|
||||
mod events;
|
||||
mod i18n;
|
||||
mod notifier;
|
||||
mod payloads;
|
||||
mod proxy;
|
||||
@@ -140,6 +141,7 @@ impl MobileConnectorPlugin {
|
||||
Arc::clone(&client),
|
||||
Arc::clone(&ctx.user_channel),
|
||||
Arc::clone(&ctx.config),
|
||||
Arc::clone(&ctx.i18n),
|
||||
bindings,
|
||||
require_device_confirmation,
|
||||
notify_delay,
|
||||
@@ -339,6 +341,12 @@ impl Plugin for MobileConnectorPlugin {
|
||||
crate::tools::mobile_tools(self)
|
||||
}
|
||||
|
||||
/// Backend translation tables — the router's error/response strings,
|
||||
/// namespaced `plugin.mobile-connector.*`. See `crate::i18n`.
|
||||
fn i18n(&self) -> Vec<core_api::i18n::LocaleBundle> {
|
||||
crate::i18n::bundles()
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any { self }
|
||||
fn as_arc_any(self: Arc<Self>) -> Arc<dyn std::any::Any + Send + Sync> { self }
|
||||
}
|
||||
|
||||
@@ -36,6 +36,14 @@ use crate::PLUGIN_ID;
|
||||
/// Cloned cheaply and safely shared between the plugin and the router.
|
||||
type StateCell = Arc<Mutex<Option<Arc<RelayApp>>>>;
|
||||
|
||||
// Namespaced i18n keys for the router's user-facing strings (backend tables in
|
||||
// `../i18n/*.json`). Resolved to the caller's language via `app.i18n()`. Every
|
||||
// use sits after `admin_app`, so the app — hence the localizer — is present.
|
||||
const KEY_RELAY_NOT_CONNECTED: &str = "plugin.mobile-connector.err.relay_not_connected";
|
||||
const KEY_ADMIN_ONLY: &str = "plugin.mobile-connector.err.admin_only";
|
||||
const KEY_USER_ID_EMPTY: &str = "plugin.mobile-connector.err.user_id_empty";
|
||||
const KEY_PUBKEY_HEX: &str = "plugin.mobile-connector.err.pubkey_hex";
|
||||
|
||||
/// Build the plugin's router. Takes the shared state cell so each request
|
||||
/// resolves the *current* `RelayApp` — not a snapshot from startup.
|
||||
pub fn build(state_cell: StateCell) -> Router {
|
||||
@@ -45,6 +53,7 @@ pub fn build(state_cell: StateCell) -> Router {
|
||||
.route("/web/pairing.js", get(|| async { serve_js(include_str!("../web/pairing.js")) }))
|
||||
.route("/web/devices.js", get(|| async { serve_js(include_str!("../web/devices.js")) }))
|
||||
.route("/web/common.js", get(|| async { serve_js(include_str!("../web/common.js")) }))
|
||||
.route("/web/i18n.js", get(|| async { serve_js(include_str!("../web/i18n.js")) }))
|
||||
// Admin pairing console API.
|
||||
.route("/pairing", post(start_pairing).delete(stop_pairing))
|
||||
.route("/devices", get(list_devices))
|
||||
@@ -69,7 +78,8 @@ async fn require_admin(app: &RelayApp, caller: &Caller) -> Result<(), Response>
|
||||
if app.user_channel.plugin_access(PLUGIN_ID, &caller.user_id).await {
|
||||
Ok(())
|
||||
} else {
|
||||
Err((StatusCode::FORBIDDEN, "admin only").into_response())
|
||||
let msg = app.i18n().for_user(&caller.user_id, KEY_ADMIN_ONLY, &[]).await;
|
||||
Err((StatusCode::FORBIDDEN, msg).into_response())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,9 +94,14 @@ fn bad_request(msg: impl Into<String>) -> Response {
|
||||
(StatusCode::BAD_REQUEST, msg.into()).into_response()
|
||||
}
|
||||
|
||||
fn decode_pubkey(hex: &str) -> Result<[u8; 32], Response> {
|
||||
skald_relay_common::crypto::decode_hex::<32>(hex)
|
||||
.ok_or_else(|| bad_request("`pubkey` must be 32-byte hex"))
|
||||
async fn decode_pubkey(app: &RelayApp, caller: &Caller, hex: &str) -> Result<[u8; 32], Response> {
|
||||
match skald_relay_common::crypto::decode_hex::<32>(hex) {
|
||||
Some(pk) => Ok(pk),
|
||||
None => {
|
||||
let msg = app.i18n().for_user(&caller.user_id, KEY_PUBKEY_HEX, &[]).await;
|
||||
Err(bad_request(msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST/DELETE /pairing ────────────────────────────────────────────────────────
|
||||
@@ -110,11 +125,8 @@ async fn start_pairing(
|
||||
// send `pairing_start` on ("WS outbound channel closed"). Fail with an
|
||||
// actionable message instead of the transport-level one.
|
||||
if !app.client().is_connected() {
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Relay not connected. Set the connector's relay_url and make sure the relay is reachable, then try again.",
|
||||
)
|
||||
.into_response();
|
||||
let msg = app.i18n().for_user(&caller.user_id, KEY_RELAY_NOT_CONNECTED, &[]).await;
|
||||
return (StatusCode::SERVICE_UNAVAILABLE, msg).into_response();
|
||||
}
|
||||
let ttl = body.ttl.unwrap_or(0).min(600);
|
||||
app.set_pending_owner(Some(caller.user_id.clone())).await;
|
||||
@@ -192,9 +204,9 @@ async fn bind_device(
|
||||
Json(body): Json<BindBody>,
|
||||
) -> Response {
|
||||
let app = match admin_app(&cell, &caller).await { Ok(a) => a, Err(r) => return r };
|
||||
let pk = match decode_pubkey(&body.pubkey) { Ok(p) => p, Err(r) => return r };
|
||||
let pk = match decode_pubkey(&app, &caller, &body.pubkey).await { Ok(p) => p, Err(r) => return r };
|
||||
if body.user_id.trim().is_empty() {
|
||||
return bad_request("`user_id` must not be empty");
|
||||
return bad_request(app.i18n().for_user(&caller.user_id, KEY_USER_ID_EMPTY, &[]).await);
|
||||
}
|
||||
match app.bind_device(pk, body.user_id, body.display).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
@@ -214,7 +226,7 @@ async fn revoke_device(
|
||||
Json(body): Json<RevokeBody>,
|
||||
) -> Response {
|
||||
let app = match admin_app(&cell, &caller).await { Ok(a) => a, Err(r) => return r };
|
||||
let pk = match decode_pubkey(&body.pubkey) { Ok(p) => p, Err(r) => return r };
|
||||
let pk = match decode_pubkey(&app, &caller, &body.pubkey).await { Ok(p) => p, Err(r) => return r };
|
||||
match app.revoke_device(pk).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
|
||||
Reference in New Issue
Block a user