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:
@@ -0,0 +1,55 @@
|
||||
//! Backend localization contract shared by the core and every plugin.
|
||||
//!
|
||||
//! Two halves:
|
||||
//! - [`LocaleBundle`] — what a plugin *declares* (its translation table for one
|
||||
//! locale), returned from `Plugin::i18n()` and collected into a single catalog
|
||||
//! at boot. Keys must be namespaced (`plugin.<id>.<key>`) so bundles from
|
||||
//! different plugins — and the core — merge without clobbering each other, and
|
||||
//! so the same key can back the frontend fragment's `t()` string.
|
||||
//! - [`I18nApi`] — what a plugin *calls* at request time to turn a key into text
|
||||
//! for the caller. Injected into `PluginContext.i18n`; the concrete impl lives
|
||||
//! in `skald-core` (it owns the locale-resolution chain and the system pool).
|
||||
//!
|
||||
//! The core never emits user-facing text through a hardcoded English literal
|
||||
//! once it can go through this seam — a plugin's own error/generated strings
|
||||
//! reach the user in the user's language, mirroring the frontend `i18n.js`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// One namespace's translation table for a single locale, as declared by a
|
||||
/// plugin (or the core). Merged into the boot-time catalog keyed by locale;
|
||||
/// keys collide across bundles only if two authors reuse the same fully
|
||||
/// qualified key, which the `plugin.<id>.` convention prevents.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocaleBundle {
|
||||
/// Locale code — `"en"`, `"it"`, `"fr"`. Must match a supported locale;
|
||||
/// anything else is simply never selected by the resolver.
|
||||
pub locale: String,
|
||||
/// Fully qualified key → translated string. Placeholders are `{name}`.
|
||||
pub strings: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl LocaleBundle {
|
||||
pub fn new(locale: impl Into<String>, strings: HashMap<String, String>) -> Self {
|
||||
Self { locale: locale.into(), strings }
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime translation, injected into [`crate::plugin::PluginContext`].
|
||||
///
|
||||
/// The catalog behind it is built once at boot from every plugin's
|
||||
/// `Plugin::i18n()`. Resolution mirrors the rest of the system: a user's
|
||||
/// `users.locale` override → the instance default → built-in English → the raw
|
||||
/// key as a last resort. Placeholders (`{name}`) are filled from `args`.
|
||||
#[async_trait]
|
||||
pub trait I18nApi: Send + Sync {
|
||||
/// Translate `key` for `user_id`, resolving *their* effective locale. Use
|
||||
/// this from any request/notification path where the target user is known.
|
||||
async fn for_user(&self, user_id: &str, key: &str, args: &[(&str, &str)]) -> String;
|
||||
|
||||
/// Translate for an already-resolved locale — for contexts with no single
|
||||
/// user (boot logs, broadcast copy) that have decided a locale by other means.
|
||||
fn get(&self, locale: &str, key: &str, args: &[(&str, &str)]) -> String;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ pub mod chatbot;
|
||||
pub mod chat_hub;
|
||||
pub mod command;
|
||||
pub mod events;
|
||||
pub mod i18n;
|
||||
pub mod image_generate;
|
||||
pub mod inbox;
|
||||
pub mod interface_tool;
|
||||
|
||||
@@ -7,6 +7,7 @@ use tokio::sync::RwLock;
|
||||
|
||||
use crate::command::CommandApi;
|
||||
use crate::config_api::ConfigApi;
|
||||
use crate::i18n::I18nApi;
|
||||
use crate::system_bus::SystemEventBus;
|
||||
use crate::image_generate::ImageGenerateRegistry;
|
||||
use crate::location::LocationUpdater;
|
||||
@@ -90,6 +91,10 @@ pub struct PluginContext {
|
||||
/// Per-user plugin configuration store (`plugin_user_configs` table).
|
||||
/// Admin-readable — never secrets.
|
||||
pub user_config: Arc<dyn PluginUserConfigApi>,
|
||||
/// Backend localization. Turns a plugin's namespaced string key into text in
|
||||
/// the caller's language (`i18n.for_user(user_id, key, args)`). The catalog
|
||||
/// is built at boot from every plugin's [`Plugin::i18n`]. See `core_api::i18n`.
|
||||
pub i18n: Arc<dyn I18nApi>,
|
||||
pub web_port: u16,
|
||||
pub remote_slot: Arc<RwLock<Option<Arc<dyn RemoteAccess>>>>,
|
||||
pub router_factory: RouterFactory,
|
||||
@@ -174,7 +179,9 @@ pub trait Plugin: Send + Sync {
|
||||
/// `/api/plugin/<id>/…` — no host APIs are injected;
|
||||
/// - it runs with the full privileges of the logged-in session (plugins are
|
||||
/// trusted — they ship in the binary);
|
||||
/// - it carries its own UI strings (reads the locale from `/api/auth/me`).
|
||||
/// - it localizes by shipping its own `{en,it,fr}` string table and
|
||||
/// registering it via `addStrings` into the host's shared `i18n.js`, then
|
||||
/// using the same `t()`/`I18nMixin` (keys namespaced `plugin.<id>.`).
|
||||
///
|
||||
/// Default: no pages.
|
||||
fn web_pages(&self) -> Vec<PluginPage> { Vec::new() }
|
||||
@@ -192,6 +199,13 @@ pub trait Plugin: Send + Sync {
|
||||
/// is stopped. Default: no tools.
|
||||
fn tools(self: Arc<Self>) -> Vec<Arc<dyn crate::tool::Tool>> { Vec::new() }
|
||||
|
||||
/// Backend translation tables this plugin contributes — one
|
||||
/// [`crate::i18n::LocaleBundle`] per locale it ships. Collected once at boot
|
||||
/// into the shared catalog behind [`PluginContext::i18n`]. Keys must be
|
||||
/// namespaced (`plugin.<id>.<key>`). Default: no strings (plugin emits no
|
||||
/// localized backend text). See `core_api::i18n`.
|
||||
fn i18n(&self) -> Vec<crate::i18n::LocaleBundle> { Vec::new() }
|
||||
|
||||
/// Returns a [`Memory`] backend if this plugin provides one.
|
||||
fn memory(&self) -> Option<Arc<dyn Memory>> { None }
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"plugin.mobile-connector.err.relay_not_connected": "Relay not connected. Set the connector's relay_url and make sure the relay is reachable, then try again.",
|
||||
"plugin.mobile-connector.err.admin_only": "Admin only.",
|
||||
"plugin.mobile-connector.err.user_id_empty": "The user must not be empty.",
|
||||
"plugin.mobile-connector.err.pubkey_hex": "The device key must be 32-byte hex."
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"plugin.mobile-connector.err.relay_not_connected": "Relais non connecté. Renseignez le relay_url du connecteur et assurez-vous que le relais est joignable, puis réessayez.",
|
||||
"plugin.mobile-connector.err.admin_only": "Administrateur uniquement.",
|
||||
"plugin.mobile-connector.err.user_id_empty": "L'utilisateur ne doit pas être vide.",
|
||||
"plugin.mobile-connector.err.pubkey_hex": "La clé de l'appareil doit être en hexadécimal de 32 octets."
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"plugin.mobile-connector.err.relay_not_connected": "Relay non connesso. Imposta il relay_url del connettore e assicurati che il relay sia raggiungibile, poi riprova.",
|
||||
"plugin.mobile-connector.err.admin_only": "Solo amministratore.",
|
||||
"plugin.mobile-connector.err.user_id_empty": "L'utente non può essere vuoto.",
|
||||
"plugin.mobile-connector.err.pubkey_hex": "La chiave del dispositivo deve essere esadecimale di 32 byte."
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -6,10 +6,23 @@
|
||||
// `Plugin::web_pages` contract): they talk only to `/api/plugin/<id>/…` and,
|
||||
// for the user directory used by the reassign dropdown, the host `/api/users`
|
||||
// (the fragment runs with the logged-in admin's full session privileges).
|
||||
//
|
||||
// i18n: the plugin ships its own dictionary (`./i18n.js`) and registers it into
|
||||
// the host's shared strings via `addStrings` (imported from the app root by the
|
||||
// absolute `/lib/i18n.js` specifier — the same module the host app uses, so
|
||||
// `t()` and `locale-changed` are shared). `MobileBase` mixes in `I18nMixin` so
|
||||
// every fragment re-renders on a language switch. Register once, at module load.
|
||||
import { LitElement } from 'lit';
|
||||
import { t, addStrings, I18nMixin } from '/lib/i18n.js';
|
||||
import STRINGS from './i18n.js';
|
||||
|
||||
addStrings(STRINGS);
|
||||
|
||||
export { t };
|
||||
|
||||
/// JSON fetch that throws the server's error text on non-2xx and tolerates an
|
||||
/// empty (204) body.
|
||||
/// empty (204) body. The server's error text is already localized (the backend
|
||||
/// resolves the caller's locale), so it is safe to surface directly.
|
||||
export async function jf(url, opts = {}) {
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
|
||||
@@ -25,27 +38,27 @@ export async function jf(url, opts = {}) {
|
||||
}
|
||||
|
||||
/// Base for the console fragments: renders into light DOM (so Bootstrap classes
|
||||
/// and the app's theme CSS variables apply) and exposes the plugin's API root
|
||||
/// from the host-set `plugin-id` attribute.
|
||||
export class MobileBase extends LitElement {
|
||||
/// and the app's theme CSS variables apply), re-renders on locale change, and
|
||||
/// exposes the plugin's API root from the host-set `plugin-id` attribute.
|
||||
export class MobileBase extends I18nMixin(LitElement) {
|
||||
createRenderRoot() { return this; }
|
||||
get api() { return `/api/plugin/${this.getAttribute('plugin-id') || 'mobile-connector'}`; }
|
||||
}
|
||||
|
||||
/// Human-friendly "time ago" for a Unix-ms timestamp (or "—" when absent).
|
||||
/// Human-friendly, localized "time ago" for a Unix-ms timestamp (or "—" when absent).
|
||||
export function ago(ms) {
|
||||
if (!ms) return '—';
|
||||
if (!ms) return t('plugin.mobile-connector.time.never');
|
||||
const s = Math.max(0, Math.floor((Date.now() - ms) / 1000));
|
||||
if (s < 60) return `${s}s ago`;
|
||||
if (s < 60) return t('plugin.mobile-connector.time.ago_s', { n: s });
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
if (m < 60) return t('plugin.mobile-connector.time.ago_m', { n: m });
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
if (h < 24) return t('plugin.mobile-connector.time.ago_h', { n: h });
|
||||
return t('plugin.mobile-connector.time.ago_d', { n: Math.floor(h / 24) });
|
||||
}
|
||||
|
||||
/// Best-effort device label from the `device_info` JSON a phone sends on hello.
|
||||
export function deviceLabel(d) {
|
||||
const info = d.device_info || {};
|
||||
return info.name || info.model || info.device || d.platform || 'Unknown device';
|
||||
return info.name || info.model || info.device || d.platform || t('plugin.mobile-connector.devices.unknown');
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
// from the host `/api/users` (the fragment runs with the admin's session).
|
||||
// Default-exports the element class; the host registers it.
|
||||
import { html, nothing } from 'lit';
|
||||
import { MobileBase, jf, ago, deviceLabel } from './common.js';
|
||||
import { MobileBase, jf, ago, deviceLabel, t } from './common.js';
|
||||
|
||||
const P = 'plugin.mobile-connector';
|
||||
|
||||
export default class MobileDevicesPage extends MobileBase {
|
||||
static get properties() {
|
||||
@@ -67,7 +69,7 @@ export default class MobileDevicesPage extends MobileBase {
|
||||
}
|
||||
|
||||
async _revoke(pubkey) {
|
||||
if (!confirm('Revoke this device? It loses access immediately.')) return;
|
||||
if (!confirm(t(`${P}.devices.revoke_confirm`))) return;
|
||||
try {
|
||||
await jf(`${this.api}/devices/revoke`, { method: 'POST', body: JSON.stringify({ pubkey }) });
|
||||
await this._load();
|
||||
@@ -79,13 +81,13 @@ export default class MobileDevicesPage extends MobileBase {
|
||||
return html`
|
||||
<div class="um-page">
|
||||
<div class="um-header d-flex justify-content-between align-items-center">
|
||||
<h2 class="um-title"><i class="bi bi-phone me-2"></i>Mobile devices</h2>
|
||||
<h2 class="um-title"><i class="bi bi-phone me-2"></i>${t(`${P}.devices.title`)}</h2>
|
||||
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._load()}>
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Refresh</button>
|
||||
<i class="bi bi-arrow-repeat me-1"></i>${t(`${P}.devices.refresh`)}</button>
|
||||
</div>
|
||||
<div style="padding:0 1.25rem 1.5rem">
|
||||
${this._error ? html`<div class="alert alert-danger py-2" style="font-size:.85rem">${this._error}</div>` : nothing}
|
||||
${loading ? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> Loading…</div>` : this._renderList()}
|
||||
${loading ? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> ${t(`${P}.devices.loading`)}</div>` : this._renderList()}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -94,15 +96,15 @@ export default class MobileDevicesPage extends MobileBase {
|
||||
const rows = this._devices || [];
|
||||
if (!rows.length) {
|
||||
return html`<div class="um-empty" style="padding:1rem">
|
||||
<i class="bi bi-phone"></i><p>No paired devices yet.</p>
|
||||
<p style="font-size:.8rem;opacity:.7">Use the <em>Pair a device</em> page to add one.</p>
|
||||
<i class="bi bi-phone"></i><p>${t(`${P}.devices.empty`)}</p>
|
||||
<p style="font-size:.8rem;opacity:.7">${t(`${P}.devices.empty_hint`)}</p>
|
||||
</div>`;
|
||||
}
|
||||
return html`
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle" style="font-size:.88rem">
|
||||
<thead><tr>
|
||||
<th>Device</th><th>State</th><th>Bound to</th><th>Last seen</th><th class="text-end">Actions</th>
|
||||
<th>${t(`${P}.devices.col_device`)}</th><th>${t(`${P}.devices.col_state`)}</th><th>${t(`${P}.devices.col_bound`)}</th><th>${t(`${P}.devices.col_last_seen`)}</th><th class="text-end">${t(`${P}.devices.col_actions`)}</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows.map(d => this._renderRow(d))}</tbody>
|
||||
</table>
|
||||
@@ -119,7 +121,7 @@ export default class MobileDevicesPage extends MobileBase {
|
||||
${d.pubkey.slice(0, 16)}…</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge ${authorized ? 'text-bg-success' : 'text-bg-secondary'}">${d.state}</span>
|
||||
<span class="badge ${authorized ? 'text-bg-success' : 'text-bg-secondary'}">${t(`${P}.devices.state_${d.state}`)}</span>
|
||||
</td>
|
||||
<td>${d.bound_user ? this._userName(d.bound_user) : html`<span class="text-body-secondary">—</span>`}</td>
|
||||
<td class="text-body-secondary">${ago(d.last_seen)}</td>
|
||||
@@ -128,12 +130,12 @@ export default class MobileDevicesPage extends MobileBase {
|
||||
<select class="form-select form-select-sm" style="width:auto"
|
||||
.value=${this._pick[d.pubkey] || d.bound_user || ''}
|
||||
@change=${(e) => { this._pick = { ...this._pick, [d.pubkey]: e.target.value }; }}>
|
||||
<option value="">Assign to…</option>
|
||||
<option value="">${t(`${P}.devices.assign_to`)}</option>
|
||||
${this._users.map(u => html`<option value=${u.id}>${u.display_name || u.username}</option>`)}
|
||||
</select>
|
||||
<button class="btn btn-sm btn-primary"
|
||||
?disabled=${!this._pick[d.pubkey] || this._pick[d.pubkey] === d.bound_user}
|
||||
@click=${() => this._bind(d.pubkey)}>Bind</button>
|
||||
@click=${() => this._bind(d.pubkey)}>${t(`${P}.devices.bind`)}</button>
|
||||
<button class="btn btn-sm btn-outline-danger" @click=${() => this._revoke(d.pubkey)}>
|
||||
<i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// Frontend translations for the mobile-connector page fragments.
|
||||
//
|
||||
// Served at `/api/plugin/mobile-connector/web/i18n.js` and imported by
|
||||
// `common.js`, which registers it into the host's shared dictionaries via
|
||||
// `addStrings` (see `web/lib/i18n.js`). Keys are namespaced `plugin.mobile-
|
||||
// connector.*` so they never collide with core keys. These are the *frontend*
|
||||
// UI strings; the plugin's backend error strings live in `../i18n/*.json` and
|
||||
// reach the browser already translated as HTTP response text.
|
||||
const P = 'plugin.mobile-connector';
|
||||
|
||||
export default {
|
||||
en: {
|
||||
[`${P}.pairing.title`]: 'Pair a device',
|
||||
[`${P}.pairing.intro`]: 'Open a pairing window, then scan the QR code with the Skald mobile app. The device is linked to you and works immediately — you can reassign it to another user from the Mobile devices page.',
|
||||
[`${P}.pairing.open`]: 'Open pairing window',
|
||||
[`${P}.pairing.opening`]: 'Opening…',
|
||||
[`${P}.pairing.qr_alt`]: 'Pairing QR',
|
||||
[`${P}.pairing.expired`]: 'Window expired',
|
||||
[`${P}.pairing.scan_within`]: 'Scan within {n}s',
|
||||
[`${P}.pairing.new_code`]: 'New code',
|
||||
[`${P}.pairing.close`]: 'Close',
|
||||
|
||||
[`${P}.devices.title`]: 'Mobile devices',
|
||||
[`${P}.devices.refresh`]: 'Refresh',
|
||||
[`${P}.devices.loading`]: 'Loading…',
|
||||
[`${P}.devices.empty`]: 'No paired devices yet.',
|
||||
[`${P}.devices.empty_hint`]: 'Use the Pair a device page to add one.',
|
||||
[`${P}.devices.col_device`]: 'Device',
|
||||
[`${P}.devices.col_state`]: 'State',
|
||||
[`${P}.devices.col_bound`]: 'Bound to',
|
||||
[`${P}.devices.col_last_seen`]: 'Last seen',
|
||||
[`${P}.devices.col_actions`]: 'Actions',
|
||||
[`${P}.devices.state_authorized`]: 'authorized',
|
||||
[`${P}.devices.state_pending`]: 'pending',
|
||||
[`${P}.devices.assign_to`]: 'Assign to…',
|
||||
[`${P}.devices.bind`]: 'Bind',
|
||||
[`${P}.devices.revoke_confirm`]: 'Revoke this device? It loses access immediately.',
|
||||
[`${P}.devices.unknown`]: 'Unknown device',
|
||||
|
||||
[`${P}.time.never`]: '—',
|
||||
[`${P}.time.ago_s`]: '{n}s ago',
|
||||
[`${P}.time.ago_m`]: '{n}m ago',
|
||||
[`${P}.time.ago_h`]: '{n}h ago',
|
||||
[`${P}.time.ago_d`]: '{n}d ago',
|
||||
},
|
||||
|
||||
it: {
|
||||
[`${P}.pairing.title`]: 'Associa un dispositivo',
|
||||
[`${P}.pairing.intro`]: 'Apri una finestra di associazione, poi scansiona il codice QR con l’app Skald sul telefono. Il dispositivo viene collegato a te e funziona subito — puoi riassegnarlo a un altro utente dalla pagina Dispositivi mobili.',
|
||||
[`${P}.pairing.open`]: 'Apri finestra di associazione',
|
||||
[`${P}.pairing.opening`]: 'Apertura…',
|
||||
[`${P}.pairing.qr_alt`]: 'QR di associazione',
|
||||
[`${P}.pairing.expired`]: 'Finestra scaduta',
|
||||
[`${P}.pairing.scan_within`]: 'Scansiona entro {n}s',
|
||||
[`${P}.pairing.new_code`]: 'Nuovo codice',
|
||||
[`${P}.pairing.close`]: 'Chiudi',
|
||||
|
||||
[`${P}.devices.title`]: 'Dispositivi mobili',
|
||||
[`${P}.devices.refresh`]: 'Aggiorna',
|
||||
[`${P}.devices.loading`]: 'Caricamento…',
|
||||
[`${P}.devices.empty`]: 'Nessun dispositivo associato.',
|
||||
[`${P}.devices.empty_hint`]: 'Usa la pagina Associa un dispositivo per aggiungerne uno.',
|
||||
[`${P}.devices.col_device`]: 'Dispositivo',
|
||||
[`${P}.devices.col_state`]: 'Stato',
|
||||
[`${P}.devices.col_bound`]: 'Assegnato a',
|
||||
[`${P}.devices.col_last_seen`]: 'Ultimo accesso',
|
||||
[`${P}.devices.col_actions`]: 'Azioni',
|
||||
[`${P}.devices.state_authorized`]: 'autorizzato',
|
||||
[`${P}.devices.state_pending`]: 'in attesa',
|
||||
[`${P}.devices.assign_to`]: 'Assegna a…',
|
||||
[`${P}.devices.bind`]: 'Associa',
|
||||
[`${P}.devices.revoke_confirm`]: 'Revocare questo dispositivo? Perderà l’accesso immediatamente.',
|
||||
[`${P}.devices.unknown`]: 'Dispositivo sconosciuto',
|
||||
|
||||
[`${P}.time.never`]: '—',
|
||||
[`${P}.time.ago_s`]: '{n}s fa',
|
||||
[`${P}.time.ago_m`]: '{n}m fa',
|
||||
[`${P}.time.ago_h`]: '{n}h fa',
|
||||
[`${P}.time.ago_d`]: '{n}g fa',
|
||||
},
|
||||
|
||||
fr: {
|
||||
[`${P}.pairing.title`]: 'Associer un appareil',
|
||||
[`${P}.pairing.intro`]: 'Ouvrez une fenêtre d’association, puis scannez le QR code avec l’app mobile Skald. L’appareil est lié à vous et fonctionne immédiatement — vous pouvez le réassigner à un autre utilisateur depuis la page Appareils mobiles.',
|
||||
[`${P}.pairing.open`]: 'Ouvrir la fenêtre d’association',
|
||||
[`${P}.pairing.opening`]: 'Ouverture…',
|
||||
[`${P}.pairing.qr_alt`]: 'QR d’association',
|
||||
[`${P}.pairing.expired`]: 'Fenêtre expirée',
|
||||
[`${P}.pairing.scan_within`]: 'Scannez sous {n}s',
|
||||
[`${P}.pairing.new_code`]: 'Nouveau code',
|
||||
[`${P}.pairing.close`]: 'Fermer',
|
||||
|
||||
[`${P}.devices.title`]: 'Appareils mobiles',
|
||||
[`${P}.devices.refresh`]: 'Actualiser',
|
||||
[`${P}.devices.loading`]: 'Chargement…',
|
||||
[`${P}.devices.empty`]: 'Aucun appareil associé.',
|
||||
[`${P}.devices.empty_hint`]: 'Utilisez la page Associer un appareil pour en ajouter un.',
|
||||
[`${P}.devices.col_device`]: 'Appareil',
|
||||
[`${P}.devices.col_state`]: 'État',
|
||||
[`${P}.devices.col_bound`]: 'Assigné à',
|
||||
[`${P}.devices.col_last_seen`]: 'Vu la dernière fois',
|
||||
[`${P}.devices.col_actions`]: 'Actions',
|
||||
[`${P}.devices.state_authorized`]: 'autorisé',
|
||||
[`${P}.devices.state_pending`]: 'en attente',
|
||||
[`${P}.devices.assign_to`]: 'Assigner à…',
|
||||
[`${P}.devices.bind`]: 'Associer',
|
||||
[`${P}.devices.revoke_confirm`]: 'Révoquer cet appareil ? Il perd l’accès immédiatement.',
|
||||
[`${P}.devices.unknown`]: 'Appareil inconnu',
|
||||
|
||||
[`${P}.time.never`]: '—',
|
||||
[`${P}.time.ago_s`]: 'il y a {n}s',
|
||||
[`${P}.time.ago_m`]: 'il y a {n}m',
|
||||
[`${P}.time.ago_h`]: 'il y a {n}h',
|
||||
[`${P}.time.ago_d`]: 'il y a {n}j',
|
||||
},
|
||||
};
|
||||
@@ -6,7 +6,9 @@
|
||||
// is usable on the phone immediately and can be reassigned later from the
|
||||
// Devices page. Default-exports the element class; the host registers it.
|
||||
import { html, nothing } from 'lit';
|
||||
import { MobileBase, jf } from './common.js';
|
||||
import { MobileBase, jf, t } from './common.js';
|
||||
|
||||
const P = 'plugin.mobile-connector';
|
||||
|
||||
export default class MobilePairingPage extends MobileBase {
|
||||
static get properties() {
|
||||
@@ -73,36 +75,34 @@ export default class MobilePairingPage extends MobileBase {
|
||||
return html`
|
||||
<div class="um-page">
|
||||
<div class="um-header">
|
||||
<h2 class="um-title"><i class="bi bi-qr-code me-2"></i>Pair a device</h2>
|
||||
<h2 class="um-title"><i class="bi bi-qr-code me-2"></i>${t(`${P}.pairing.title`)}</h2>
|
||||
</div>
|
||||
<div style="padding:0 1.25rem 1.5rem; max-width:640px">
|
||||
${this._error ? html`<div class="alert alert-danger py-2" style="font-size:.85rem">${this._error}</div>` : nothing}
|
||||
|
||||
${!this._session ? html`
|
||||
<p class="text-body-secondary" style="font-size:.9rem">
|
||||
Open a pairing window, then scan the QR code with the Skald mobile app.
|
||||
The device is linked to <strong>you</strong> and works immediately — you can
|
||||
reassign it to another user from the <em>Mobile devices</em> page.
|
||||
${t(`${P}.pairing.intro`)}
|
||||
</p>
|
||||
<button class="btn btn-primary" ?disabled=${this._busy} @click=${() => this._open()}>
|
||||
<i class="bi bi-qr-code-scan me-1"></i>${this._busy ? 'Opening…' : 'Open pairing window'}
|
||||
<i class="bi bi-qr-code-scan me-1"></i>${this._busy ? t(`${P}.pairing.opening`) : t(`${P}.pairing.open`)}
|
||||
</button>
|
||||
` : html`
|
||||
<div class="d-flex flex-column align-items-center gap-3 p-3"
|
||||
style="border:1px solid var(--border-color,#ddd); border-radius:var(--radius-md,12px)">
|
||||
<img src=${this._session.url} alt="Pairing QR" width="256" height="256"
|
||||
<img src=${this._session.url} alt=${t(`${P}.pairing.qr_alt`)} width="256" height="256"
|
||||
style="image-rendering:pixelated; ${expired ? 'opacity:.25' : ''}" />
|
||||
${expired
|
||||
? html`<div class="text-danger" style="font-size:.9rem"><i class="bi bi-clock-history me-1"></i>Window expired</div>`
|
||||
? html`<div class="text-danger" style="font-size:.9rem"><i class="bi bi-clock-history me-1"></i>${t(`${P}.pairing.expired`)}</div>`
|
||||
: html`<div class="text-body-secondary" style="font-size:.9rem">
|
||||
Scan within <strong>${this._remain}s</strong>
|
||||
${t(`${P}.pairing.scan_within`, { n: this._remain })}
|
||||
</div>`}
|
||||
<div class="d-flex gap-2">
|
||||
${expired
|
||||
? html`<button class="btn btn-primary btn-sm" @click=${() => this._open()}>
|
||||
<i class="bi bi-arrow-repeat me-1"></i>New code</button>`
|
||||
<i class="bi bi-arrow-repeat me-1"></i>${t(`${P}.pairing.new_code`)}</button>`
|
||||
: html`<button class="btn btn-outline-secondary btn-sm" @click=${() => this._stop()}>
|
||||
<i class="bi bi-x-lg me-1"></i>Close</button>`}
|
||||
<i class="bi bi-x-lg me-1"></i>${t(`${P}.pairing.close`)}</button>`}
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
@@ -6,9 +6,9 @@ edition = "2024"
|
||||
# The headless application core: database + crypto + identity, the LLM stack,
|
||||
# tools, MCP, plugins-as-a-registry, sessions.
|
||||
#
|
||||
# Deliberately knows nothing about the process shell around it. No Tauri, no
|
||||
# concrete plugin crates (it only ever sees `Arc<dyn Plugin>` from `core-api`),
|
||||
# no `axum` server — `skald` and `skald-setup` are both consumers.
|
||||
# Deliberately knows nothing about the process shell around it. No concrete
|
||||
# plugin crates (it only ever sees `Arc<dyn Plugin>` from `core-api`), no `axum`
|
||||
# server — `skald` and `skald-setup` are both consumers.
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.8", features = ["ws", "multipart"] }
|
||||
|
||||
@@ -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