feat: mirada standalone — compositor Wayland + WM sobre Llimphi (build magro)
Stack de display extraído del monorepo: compositor teselante (Cuerpo smithay + Cerebro WM agnóstico), greeter PAM, portal XDG, CLI de control. Llimphi se consume por git desde su repo publicado; las hojas compartidas (format, auth-core, rimay-localize, wawa-config, app-bus) y el widget menubar van vendorizados. Sin el asistente IA (pluma-llm) ni la barra web wasm — el compositor no los necesita. cargo check --workspace pasa (18 crates, 0 warn). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "app-bus"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "app-bus — registro único de aplicaciones de gioser + protocolo de menú global (Archivo/Editar/Ayuda) + bus de eventos foco/lanzamiento + trait Launcher. Lo consultan los launchers (mirada, shuma, wawa) en vez de reimplementar el despacho cada uno. Los datos + el trait son no_std; el descubrimiento (fs/TOML), el spawn de procesos y el Bus van detrás del feature `std`."
|
||||
|
||||
[features]
|
||||
default = ["std"]
|
||||
# `std` enciende el descubrimiento por filesystem/TOML, el spawn de
|
||||
# procesos del host (ProcessLauncher) y el Bus pub/sub (std::sync::mpsc).
|
||||
# Sin `std`, el crate queda en datos + trait Launcher + AppMenu, listo para
|
||||
# espejar en el kernel de wawa.
|
||||
std = ["serde/std", "toml", "directories"]
|
||||
|
||||
[dependencies]
|
||||
# serde directo (no workspace) para poder apagar default-features en el
|
||||
# build no_std — la versión workspace fuerza std.
|
||||
serde = { version = "1", default-features = false, features = ["derive", "alloc"] }
|
||||
toml = { workspace = true, optional = true }
|
||||
directories = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,38 @@
|
||||
# app-bus — bus de eventos in-proc para apps Llimphi
|
||||
|
||||
Bus de **publish/subscribe** tipado, síncrono y en memoria, para coordinar apps
|
||||
dentro del mismo proceso. Cubre tres familias de eventos transversales: **foco**
|
||||
(qué app/ventana lo tiene), **navegación** (abrir/cerrar/enfocar una app o ruta)
|
||||
y **notificaciones** efímeras.
|
||||
|
||||
## Qué expone
|
||||
|
||||
- `AppBus` — handle compartible (`Clone`, internamente `Arc<RwLock<…>>`).
|
||||
- `Event` — enum transversal: `FocusChanged` / `Navigate` / `CloseApp` / `Notify`.
|
||||
- `NotifyLevel` — `Info` / `Warn` / `Error`.
|
||||
- `Subscription` — guard RAII: al soltarlo se cancela la suscripción.
|
||||
- `publish(Event)` entrega de forma síncrona a todos los suscriptores; entrega
|
||||
anidada si un callback publica desde su propio handler.
|
||||
|
||||
## No-objetivos
|
||||
|
||||
- No es un bus interproceso ni de red (eso es Akasha / app-channel).
|
||||
- No persiste eventos ni garantiza entrega tras reinicio.
|
||||
- No ordena por prioridad.
|
||||
|
||||
## Estado (2026-05-31)
|
||||
|
||||
### Hecho
|
||||
- Bus pub/sub completo: `publish`, `subscribe`, cancelación por guard RAII.
|
||||
- Enum `Event` con foco/navegación/cierre/notificaciones.
|
||||
- Consumido por `launcher-llimphi` (dispara navegación/lanzamiento).
|
||||
|
||||
### Pendiente
|
||||
- Adaptador multiproceso (hoy estrictamente in-proc).
|
||||
- Entrega diferida / cola (hoy reentrancia anidada síncrona).
|
||||
- Filtrado por tipo de evento en `subscribe` (hoy el callback filtra).
|
||||
|
||||
## Lugar en el repo
|
||||
|
||||
`shared/app-bus` — canal in-proc de grano fino. El plano de control a más alto
|
||||
nivel es `shared/sandokan` (ver su SDD.md).
|
||||
@@ -0,0 +1,32 @@
|
||||
# Reproductor multimedia de la suite (02_ruway/media).
|
||||
#
|
||||
# Copiá este archivo a ~/.config/gioser/apps/media.toml para que los launchers
|
||||
# (el menú de inicio de pata, el spotlight) y el "abrir con" del navegador lo
|
||||
# descubran. El binario `media-app` debe estar en el PATH (cargo install) o poné
|
||||
# su ruta absoluta en `exec`.
|
||||
|
||||
id = "media"
|
||||
label = "Media"
|
||||
icon = "▶"
|
||||
category = "ruway"
|
||||
|
||||
# Los mimes que sabe abrir (open-with). Deben coincidir con los que pata deriva
|
||||
# de la extensión (02_ruway/pata/pata-llimphi/src/open.rs::mime_for_path).
|
||||
handles = [
|
||||
"video/mp4",
|
||||
"video/x-matroska",
|
||||
"video/webm",
|
||||
"video/quicktime",
|
||||
"video/x-msvideo",
|
||||
"audio/mpeg",
|
||||
"audio/flac",
|
||||
"audio/wav",
|
||||
"audio/ogg",
|
||||
"audio/opus",
|
||||
]
|
||||
|
||||
[launch]
|
||||
exec = "media-app"
|
||||
# `%f` = la ruta del archivo a abrir (convención freedesktop). Sin placeholder,
|
||||
# la ruta se agrega igual como último argumento.
|
||||
args = ["%f"]
|
||||
@@ -0,0 +1,40 @@
|
||||
# Editor de texto/código de la suite (02_ruway/nada): file tree + text-editor
|
||||
# Llimphi sobre archivos reales. Acepta una ruta (archivo o directorio) como
|
||||
# argumento.
|
||||
#
|
||||
# Copiá este archivo a ~/.config/gioser/apps/nada.toml. El binario `nada` debe
|
||||
# estar en el PATH (cargo install -p nada) o poné su ruta absoluta en `exec`.
|
||||
|
||||
id = "nada"
|
||||
label = "Nada"
|
||||
icon = "✎"
|
||||
category = "ruway"
|
||||
|
||||
# Texto y código: lo que el text-editor de Llimphi sabe editar. Coincide con la
|
||||
# tabla de mimes de pata (open.rs::mime_for_path).
|
||||
handles = [
|
||||
"text/plain",
|
||||
"text/markdown",
|
||||
"text/x-rst",
|
||||
"text/csv",
|
||||
"text/x-rust",
|
||||
"text/x-python",
|
||||
"text/javascript",
|
||||
"text/typescript",
|
||||
"text/x-c",
|
||||
"text/x-c++",
|
||||
"text/x-go",
|
||||
"text/x-java",
|
||||
"text/x-ruby",
|
||||
"application/x-shellscript",
|
||||
"application/toml",
|
||||
"application/json",
|
||||
"application/yaml",
|
||||
"application/xml",
|
||||
"text/html",
|
||||
"text/css",
|
||||
]
|
||||
|
||||
[launch]
|
||||
exec = "nada"
|
||||
args = ["%f"]
|
||||
@@ -0,0 +1,795 @@
|
||||
//! `app-bus` — el cimiento del menú de aplicaciones de gioser.
|
||||
//!
|
||||
//! Hoy hay tres lanzadores que no comparten nada: `mirada-launcher`
|
||||
//! (TOML propio, `std::process`), `shuma-module-launcher` (otro TOML,
|
||||
//! `process_group`) y el launcher in-kernel de wawa (Manifiesto, WASM).
|
||||
//! Cada uno reimplementa "qué apps existen y cómo se lanzan". Este crate
|
||||
//! es la tabla única que todos consultan.
|
||||
//!
|
||||
//! Cuatro piezas, en capas:
|
||||
//!
|
||||
//! 1. **Registro** ([`AppRegistry`] + [`AppEntry`]): qué apps hay, cómo se
|
||||
//! lanzan ([`Launch`]) y qué mimes/lentes saben abrir (open-with).
|
||||
//! Se descubre de `~/.config/gioser/apps/*.toml` (feature `std`).
|
||||
//! 2. **Menú global** ([`AppMenu`]/[`Menu`]/[`MenuItem`]): el clásico
|
||||
//! Archivo/Editar/Ayuda que la app *declara*. Cuando hay una barra de
|
||||
//! launcher presente, ésta lo *adopta* y la app deja de pintarlo en su
|
||||
//! ventana — el comportamiento "menú global" de macOS.
|
||||
//! 3. **Launcher** ([`Launcher`] trait + [`LaunchError`]): la *instrucción
|
||||
//! de ejecución* abstracta. El host implementa con `std::process`
|
||||
//! ([`ProcessLauncher`]), wawa con instanciación WASM, shuma
|
||||
//! despachando `action`. El motor de launcher llama al trait y no se
|
||||
//! entera de en qué entorno corre.
|
||||
//! 4. **Bus** ([`Bus`] + [`BusEvent`]): pub/sub in-process de foco /
|
||||
//! cambio de menú / pedido de lanzamiento / comando. La versión
|
||||
//! cross-proceso montará sobre el broker de brahman más adelante.
|
||||
//!
|
||||
//! Los **datos** ([`AppEntry`], [`Launch`], [`AppMenu`]…) y el **trait
|
||||
//! [`Launcher`]** son `no_std + alloc`. El descubrimiento por filesystem,
|
||||
//! el spawn de procesos y el [`Bus`] viven detrás del feature `std`.
|
||||
|
||||
#![cfg_attr(not(feature = "std"), no_std)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
extern crate alloc;
|
||||
|
||||
use alloc::string::String;
|
||||
use alloc::vec::Vec;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// =====================================================================
|
||||
// Registro de apps
|
||||
// =====================================================================
|
||||
|
||||
/// Cómo se enciende una app. Los tres mundos de gioser:
|
||||
/// `Exec` (binario del host), `Action` (acción interna del chasis que la
|
||||
/// hospeda — p.ej. `focus:shell`) y `Wasm` (módulo en el almacén de wawa,
|
||||
/// direccionado por hash de bytecode).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Launch {
|
||||
/// Spawnear un comando/binario del host.
|
||||
Exec { program: String, args: Vec<String> },
|
||||
/// Acción interna a despachar por el host (no spawnea proceso).
|
||||
Action(String),
|
||||
/// App WASM de wawa, por hash de bytecode (hex) en el almacén.
|
||||
Wasm { bytecode_hex: String },
|
||||
}
|
||||
|
||||
/// Una app registrada — la fila de la tabla que ven los launchers.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AppEntry {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
/// Glyph/emoji o ruta de ícono. Sin imponer formato — el launcher
|
||||
/// decide cómo pintarlo (texto en el dock MVP).
|
||||
pub icon: Option<String>,
|
||||
/// Agrupador opcional para la grilla/spotlight (p.ej. cuadrante).
|
||||
pub category: Option<String>,
|
||||
pub launch: Launch,
|
||||
/// Mimes/lentes que esta app sabe abrir (open-with). Vacío = no es
|
||||
/// visor; el registro de visores de nahual-shell se alimenta de acá.
|
||||
pub handles: Vec<String>,
|
||||
}
|
||||
|
||||
impl AppEntry {
|
||||
/// `true` si la app declara saber abrir `mime`.
|
||||
pub fn handles_mime(&self, mime: &str) -> bool {
|
||||
self.handles.iter().any(|m| m == mime)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "std")]
|
||||
impl AppEntry {
|
||||
/// Enciende la app vía `std::process`. Sólo `Exec` spawnea; `Action`/
|
||||
/// `Wasm` devuelven `Ok(None)` — los despacha el host (chasis/kernel).
|
||||
pub fn spawn(&self) -> std::io::Result<Option<std::process::Child>> {
|
||||
match &self.launch {
|
||||
Launch::Exec { program, args } => std::process::Command::new(program)
|
||||
.args(args)
|
||||
.spawn()
|
||||
.map(Some),
|
||||
Launch::Action(_) | Launch::Wasm { .. } => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// **Open-with out-of-process**: abre `target` con esta app. Para `Exec`,
|
||||
/// spawnea el binario sustituyendo el placeholder `%f`/`%u` en los args
|
||||
/// por `target`; si ningún arg lo trae, agrega `target` como último
|
||||
/// argumento (semántica estilo freedesktop `Exec=app %f`). `Action`/`Wasm`
|
||||
/// devuelven `Ok(None)`: el target lo despacha el host (chasis a una vista
|
||||
/// in-process, o kernel de wawa a una app WASM), no un proceso del SO.
|
||||
pub fn open(&self, target: &str) -> std::io::Result<Option<std::process::Child>> {
|
||||
match &self.launch {
|
||||
Launch::Exec { program, args } => std::process::Command::new(program)
|
||||
.args(expand_target(args, target))
|
||||
.spawn()
|
||||
.map(Some),
|
||||
Launch::Action(_) | Launch::Wasm { .. } => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sustituye los placeholders `%f`/`%u` por `target` en `args`. Si ninguno
|
||||
/// aparece, agrega `target` como argumento final — la convención de
|
||||
/// freedesktop (`Exec=app %f`) que entiende cualquier "abrir con".
|
||||
#[cfg(feature = "std")]
|
||||
pub fn expand_target(args: &[String], target: &str) -> Vec<String> {
|
||||
let mut sustituido = false;
|
||||
let mut out: Vec<String> = args
|
||||
.iter()
|
||||
.map(|a| {
|
||||
if a.contains("%f") || a.contains("%u") {
|
||||
sustituido = true;
|
||||
a.replace("%f", target).replace("%u", target)
|
||||
} else {
|
||||
a.clone()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if !sustituido {
|
||||
out.push(target.to_string());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ----- forma en disco (TOML) -----
|
||||
|
||||
/// Espejo serde del archivo `<id>.toml`. La `[launch]` es una tabla con
|
||||
/// campos opcionales en vez de un enum etiquetado — toml 0.8 trata los
|
||||
/// enums internamente etiquetados de forma quisquillosa, así que
|
||||
/// resolvemos a mano a [`Launch`]. Sólo se usa al parsear TOML (`std`).
|
||||
#[cfg(feature = "std")]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct AppFile {
|
||||
id: String,
|
||||
label: String,
|
||||
#[serde(default)]
|
||||
icon: Option<String>,
|
||||
#[serde(default)]
|
||||
category: Option<String>,
|
||||
#[serde(default)]
|
||||
handles: Vec<String>,
|
||||
launch: LaunchFile,
|
||||
}
|
||||
|
||||
#[cfg(feature = "std")]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct LaunchFile {
|
||||
#[serde(default)]
|
||||
exec: Option<String>,
|
||||
#[serde(default)]
|
||||
args: Vec<String>,
|
||||
#[serde(default)]
|
||||
action: Option<String>,
|
||||
#[serde(default)]
|
||||
wasm: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "std")]
|
||||
impl LaunchFile {
|
||||
fn resolve(self) -> Option<Launch> {
|
||||
if let Some(program) = self.exec {
|
||||
Some(Launch::Exec {
|
||||
program,
|
||||
args: self.args,
|
||||
})
|
||||
} else if let Some(action) = self.action {
|
||||
Some(Launch::Action(action))
|
||||
} else {
|
||||
self.wasm.map(|bytecode_hex| Launch::Wasm { bytecode_hex })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "std")]
|
||||
impl AppFile {
|
||||
fn into_entry(self) -> Option<AppEntry> {
|
||||
Some(AppEntry {
|
||||
id: self.id,
|
||||
label: self.label,
|
||||
icon: self.icon,
|
||||
category: self.category,
|
||||
handles: self.handles,
|
||||
launch: self.launch.resolve()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsea una entrada de app desde texto TOML. Devuelve `None` si no
|
||||
/// parsea o si la `[launch]` no nombra ningún modo (`exec`/`action`/`wasm`).
|
||||
#[cfg(feature = "std")]
|
||||
pub fn parse_entry(toml_src: &str) -> Option<AppEntry> {
|
||||
toml::from_str::<AppFile>(toml_src)
|
||||
.ok()
|
||||
.and_then(AppFile::into_entry)
|
||||
}
|
||||
|
||||
/// Directorio canónico del registro: `~/.config/gioser/apps/`.
|
||||
#[cfg(feature = "std")]
|
||||
pub fn apps_dir() -> Option<std::path::PathBuf> {
|
||||
directories::BaseDirs::new().map(|b| b.config_dir().join("gioser").join("apps"))
|
||||
}
|
||||
|
||||
/// La tabla de apps. Inmutable tras descubrir — recargar = volver a
|
||||
/// `discover`. Barato: son pocos archivos y no es hot-path.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AppRegistry {
|
||||
entries: Vec<AppEntry>,
|
||||
}
|
||||
|
||||
impl AppRegistry {
|
||||
pub fn new(mut entries: Vec<AppEntry>) -> Self {
|
||||
// sort_unstable_by para no exigir alloc extra (vive en core).
|
||||
entries.sort_unstable_by(|a, b| a.label.cmp(&b.label));
|
||||
Self { entries }
|
||||
}
|
||||
|
||||
pub fn all(&self) -> &[AppEntry] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<&AppEntry> {
|
||||
self.entries.iter().find(|e| e.id == id)
|
||||
}
|
||||
|
||||
/// Apps que declaran abrir `mime` — para el open-with universal.
|
||||
pub fn handlers_for(&self, mime: &str) -> Vec<&AppEntry> {
|
||||
self.entries.iter().filter(|e| e.handles_mime(mime)).collect()
|
||||
}
|
||||
|
||||
/// Apps de una categoría, en orden de label (para grilla/spotlight).
|
||||
pub fn in_category(&self, category: &str) -> Vec<&AppEntry> {
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|e| e.category.as_deref() == Some(category))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "std")]
|
||||
impl AppRegistry {
|
||||
/// Descubre del dir canónico. Vacío si no hay config dir o el dir no
|
||||
/// existe — la app sigue, sólo sin entradas.
|
||||
pub fn discover() -> Self {
|
||||
apps_dir().map(Self::from_dir).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// **Open-with universal**: elige el primer handler de `mime` (orden de
|
||||
/// label) y le abre `target` out-of-process vía [`AppEntry::open`].
|
||||
/// Devuelve el `AppEntry` elegido y su `Child` (o `None` en el child si
|
||||
/// la app es `Action`/`Wasm`, que despacha el host). `Ok(None)` si ninguna
|
||||
/// app registrada declara abrir ese mime — el caller cae a su visor
|
||||
/// in-process por defecto (p.ej. el `viewer_registry` de nahual-shell).
|
||||
pub fn open_with(
|
||||
&self,
|
||||
mime: &str,
|
||||
target: &str,
|
||||
) -> std::io::Result<Option<(&AppEntry, Option<std::process::Child>)>> {
|
||||
match self.handlers_for(mime).into_iter().next() {
|
||||
Some(entry) => Ok(Some((entry, entry.open(target)?))),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Escanea `<dir>/*.toml`. Ignora en silencio los que no parsean
|
||||
/// (con una nota a stderr), igual que el resto de los loaders del repo.
|
||||
pub fn from_dir(dir: impl AsRef<std::path::Path>) -> Self {
|
||||
let dir = dir.as_ref();
|
||||
let mut entries = Vec::new();
|
||||
if let Ok(rd) = std::fs::read_dir(dir) {
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
if p.extension().and_then(|s| s.to_str()) != Some("toml") {
|
||||
continue;
|
||||
}
|
||||
match std::fs::read_to_string(&p) {
|
||||
Ok(src) => match parse_entry(&src) {
|
||||
Some(entry) => entries.push(entry),
|
||||
None => eprintln!("app-bus: {p:?} no declara una app válida"),
|
||||
},
|
||||
Err(err) => eprintln!("app-bus: no se pudo leer {p:?}: {err}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::new(entries)
|
||||
}
|
||||
}
|
||||
|
||||
/// Siembra manifests por defecto en [`apps_dir`] si todavía no hay
|
||||
/// ninguno, para que [`AppRegistry::discover`] devuelva las apps del repo
|
||||
/// en una máquina recién instalada. No pisa nada si ya existe algún
|
||||
/// `*.toml`. Devuelve cuántos manifests escribió.
|
||||
#[cfg(feature = "std")]
|
||||
pub fn seed_default_apps() -> std::io::Result<usize> {
|
||||
let Some(dir) = apps_dir() else {
|
||||
return Ok(0);
|
||||
};
|
||||
// Si ya hay manifests, respetar la config del usuario y no tocar nada.
|
||||
if let Ok(rd) = std::fs::read_dir(&dir) {
|
||||
let ya_hay = rd.flatten().any(|e| {
|
||||
e.path().extension().and_then(|s| s.to_str()) == Some("toml")
|
||||
});
|
||||
if ya_hay {
|
||||
return Ok(0);
|
||||
}
|
||||
}
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
|
||||
// (id, label, icono, binario, cuadrante). Los binarios son los nombres
|
||||
// de crate ejecutables del workspace; el cuadrante alimenta la grilla.
|
||||
const DEFAULTS: &[(&str, &str, &str, &str, &str)] = &[
|
||||
("cosmos", "Cosmos", "✶", "cosmos-app-llimphi", "yachay"),
|
||||
("nada", "Nada", "✎", "nada", "ruway"),
|
||||
("pluma", "Pluma", "✒", "pluma-editor-llimphi", "unanchay"),
|
||||
("nahual", "Nahual", "❖", "nahual-shell-llimphi", "ruway"),
|
||||
("dominium", "Dominium", "◉", "dominium-app-llimphi", "yachay"),
|
||||
("tinkuy", "Tinkuy", "⚛", "tinkuy-llimphi", "yachay"),
|
||||
("takiy", "Takiy", "♪", "takiy-app-llimphi", "ruway"),
|
||||
("media", "Media", "▶", "media-app", "ruway"),
|
||||
("tullpu", "Tullpu", "✦", "tullpu-app-llimphi", "ruway"),
|
||||
("supay", "Supay", "✷", "supay-app-llimphi", "ruway"),
|
||||
("sandokan-monitor", "Monitor", "❤", "sandokan-monitor", "ukupacha"),
|
||||
];
|
||||
|
||||
let mut escritos = 0;
|
||||
for (id, label, icon, exec, cat) in DEFAULTS {
|
||||
let toml = alloc::format!(
|
||||
"id = \"{id}\"\nlabel = \"{label}\"\nicon = \"{icon}\"\ncategory = \"{cat}\"\n\n[launch]\nexec = \"{exec}\"\n"
|
||||
);
|
||||
std::fs::write(dir.join(alloc::format!("{id}.toml")), toml)?;
|
||||
escritos += 1;
|
||||
}
|
||||
Ok(escritos)
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Menú global (Archivo / Editar / Ayuda …)
|
||||
// =====================================================================
|
||||
|
||||
/// Un ítem de menú. `command` es el id que la app entiende: la barra lo
|
||||
/// re-emite por el [`Bus`] como [`BusEvent::Command`] y la app focuseada
|
||||
/// lo ejecuta. `shortcut` es sólo para pintar (la app sigue dueña del
|
||||
/// binding real).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MenuItem {
|
||||
pub label: String,
|
||||
pub command: String,
|
||||
#[serde(default)]
|
||||
pub shortcut: Option<String>,
|
||||
/// Glifo (unicode) opcional para el gutter de íconos del dropdown.
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
#[serde(default = "yes")]
|
||||
pub enabled: bool,
|
||||
/// Dibujar un separador *antes* de este ítem.
|
||||
#[serde(default)]
|
||||
pub separator_before: bool,
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl MenuItem {
|
||||
pub fn new(label: impl Into<String>, command: impl Into<String>) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
command: command.into(),
|
||||
shortcut: None,
|
||||
icon: None,
|
||||
enabled: true,
|
||||
separator_before: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shortcut(mut self, s: impl Into<String>) -> Self {
|
||||
self.shortcut = Some(s.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Glifo del gutter izquierdo (unicode).
|
||||
pub fn icon(mut self, glyph: impl Into<String>) -> Self {
|
||||
self.icon = Some(glyph.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn disabled(mut self) -> Self {
|
||||
self.enabled = false;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn separated(mut self) -> Self {
|
||||
self.separator_before = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Un menú raíz (Archivo, Editar, Ayuda…) con sus ítems.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Menu {
|
||||
pub label: String,
|
||||
pub items: Vec<MenuItem>,
|
||||
}
|
||||
|
||||
impl Menu {
|
||||
pub fn new(label: impl Into<String>) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
items: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn item(mut self, it: MenuItem) -> Self {
|
||||
self.items.push(it);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// El menú global completo de una app. La app lo declara; la barra de
|
||||
/// launcher lo adopta (y entonces la app no lo pinta en su ventana).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AppMenu {
|
||||
pub menus: Vec<Menu>,
|
||||
}
|
||||
|
||||
impl AppMenu {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn menu(mut self, m: Menu) -> Self {
|
||||
self.menus.push(m);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.menus.is_empty()
|
||||
}
|
||||
|
||||
/// Esqueleto estándar Archivo/Editar/Ayuda — punto de partida para que
|
||||
/// toda app tenga un menú base coherente sin reinventarlo. Los
|
||||
/// `command` siguen la convención `menu.<verbo>`; la app mapea los que
|
||||
/// le sirven y deja `disabled` los que no.
|
||||
pub fn standard() -> Self {
|
||||
Self::new()
|
||||
.menu(
|
||||
Menu::new("Archivo")
|
||||
.item(MenuItem::new("Nuevo", "file.new").shortcut("Ctrl+N"))
|
||||
.item(MenuItem::new("Abrir…", "file.open").shortcut("Ctrl+O"))
|
||||
.item(MenuItem::new("Guardar", "file.save").shortcut("Ctrl+S"))
|
||||
.item(MenuItem::new("Cerrar", "file.close").shortcut("Ctrl+W").separated()),
|
||||
)
|
||||
.menu(
|
||||
Menu::new("Editar")
|
||||
.item(MenuItem::new("Deshacer", "edit.undo").shortcut("Ctrl+Z"))
|
||||
.item(MenuItem::new("Rehacer", "edit.redo").shortcut("Ctrl+Y"))
|
||||
.item(MenuItem::new("Cortar", "edit.cut").shortcut("Ctrl+X").separated())
|
||||
.item(MenuItem::new("Copiar", "edit.copy").shortcut("Ctrl+C"))
|
||||
.item(MenuItem::new("Pegar", "edit.paste").shortcut("Ctrl+V")),
|
||||
)
|
||||
.menu(
|
||||
Menu::new("Ayuda")
|
||||
.item(MenuItem::new("Atajos", "help.shortcuts").shortcut("F1"))
|
||||
.item(MenuItem::new("Acerca de", "help.about")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Launcher — la instrucción de ejecución abstracta
|
||||
// =====================================================================
|
||||
|
||||
/// Por qué no se pudo lanzar una app.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LaunchError {
|
||||
/// Este `Launcher` no maneja el modo de la app (p.ej. un host que no
|
||||
/// instancia WASM, o wawa que no spawnea procesos del host).
|
||||
Unsupported,
|
||||
/// El lanzamiento falló; mensaje libre.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// La *instrucción de ejecución* abstracta. El motor de launcher
|
||||
/// (`launcher-core`/`launcher-llimphi`) llama a `launch` y no sabe en qué
|
||||
/// entorno corre — host, shuma o wawa cada uno trae su impl.
|
||||
pub trait Launcher {
|
||||
fn launch(&self, app: &AppEntry) -> Result<(), LaunchError>;
|
||||
}
|
||||
|
||||
/// Launcher del host: spawnea binarios vía `std::process`. No maneja
|
||||
/// `Action`/`Wasm` (devuelve `Unsupported` — esos los resuelve el chasis).
|
||||
#[cfg(feature = "std")]
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct ProcessLauncher;
|
||||
|
||||
#[cfg(feature = "std")]
|
||||
impl Launcher for ProcessLauncher {
|
||||
fn launch(&self, app: &AppEntry) -> Result<(), LaunchError> {
|
||||
match app.spawn() {
|
||||
Ok(Some(_child)) => Ok(()),
|
||||
Ok(None) => Err(LaunchError::Unsupported),
|
||||
Err(e) => Err(LaunchError::Failed(alloc::string::ToString::to_string(&e))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Bus de eventos (pub/sub in-process) — sólo `std`
|
||||
// =====================================================================
|
||||
|
||||
/// Lo que viaja por el bus. El flujo del menú global: una app toma foco
|
||||
/// → `AppFocused` + `MenuChanged` → la barra adopta el menú → el usuario
|
||||
/// clickea un ítem → la barra emite `Command` → la app focuseada lo
|
||||
/// ejecuta. El dock/spotlight emiten `LaunchRequested` y el shell lo
|
||||
/// resuelve contra el [`AppRegistry`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum BusEvent {
|
||||
/// Una app tomó foco — la barra debería adoptar su menú.
|
||||
AppFocused { app_id: String },
|
||||
/// El menú de una app cambió (ítems habilitados/labels dinámicos).
|
||||
MenuChanged { app_id: String, menu: AppMenu },
|
||||
/// Dock/spotlight pidieron lanzar una app por id.
|
||||
LaunchRequested { app_id: String },
|
||||
/// La barra global disparó un comando del menú hacia la app focuseada.
|
||||
Command { app_id: String, command: String },
|
||||
}
|
||||
|
||||
/// Bus pub/sub mínimo y `Send + Sync`: fan-out a todos los suscriptores.
|
||||
/// Un suscriptor caído (receiver dropeado) se poda en el próximo publish.
|
||||
/// Clonar el `Bus` comparte el mismo conjunto de suscriptores.
|
||||
#[cfg(feature = "std")]
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Bus {
|
||||
subs: std::sync::Arc<std::sync::Mutex<Vec<std::sync::mpsc::Sender<BusEvent>>>>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "std")]
|
||||
impl Bus {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Crea un canal y devuelve su extremo de recepción. El emisor queda
|
||||
/// registrado para recibir cada `publish` futuro.
|
||||
pub fn subscribe(&self) -> std::sync::mpsc::Receiver<BusEvent> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
self.subs.lock().unwrap().push(tx);
|
||||
rx
|
||||
}
|
||||
|
||||
/// Emite a todos los suscriptores vivos. Devuelve cuántos lo recibieron.
|
||||
pub fn publish(&self, ev: BusEvent) -> usize {
|
||||
let mut subs = self.subs.lock().unwrap();
|
||||
subs.retain(|tx| tx.send(ev.clone()).is_ok());
|
||||
subs.len()
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Tests (corren con default features = std)
|
||||
// =====================================================================
|
||||
|
||||
#[cfg(all(test, feature = "std"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_exec_entry() {
|
||||
let src = r#"
|
||||
id = "cosmos"
|
||||
label = "Cosmos"
|
||||
icon = "✶"
|
||||
category = "yachay"
|
||||
handles = ["application/x-cosmos-chart"]
|
||||
[launch]
|
||||
exec = "cosmos-app-llimphi"
|
||||
args = ["--release"]
|
||||
"#;
|
||||
let e = parse_entry(src).expect("parsea");
|
||||
assert_eq!(e.id, "cosmos");
|
||||
assert_eq!(e.icon.as_deref(), Some("✶"));
|
||||
assert!(e.handles_mime("application/x-cosmos-chart"));
|
||||
assert_eq!(
|
||||
e.launch,
|
||||
Launch::Exec {
|
||||
program: "cosmos-app-llimphi".into(),
|
||||
args: vec!["--release".into()],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_action_and_wasm() {
|
||||
let a = parse_entry("id='s'\nlabel='Shell'\n[launch]\naction='focus:shell'").unwrap();
|
||||
assert_eq!(a.launch, Launch::Action("focus:shell".into()));
|
||||
let w = parse_entry("id='h'\nlabel='Hola'\n[launch]\nwasm='deadbeef'").unwrap();
|
||||
assert_eq!(w.launch, Launch::Wasm { bytecode_hex: "deadbeef".into() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_sin_modo_es_none() {
|
||||
assert!(parse_entry("id='x'\nlabel='X'\n[launch]").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_from_dir_y_consultas() {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("app-bus-test-{}-{}", std::process::id(), "reg"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("cosmos.toml"),
|
||||
"id='cosmos'\nlabel='Cosmos'\ncategory='yachay'\nhandles=['x/chart']\n[launch]\nexec='cosmos-app-llimphi'",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
dir.join("nada.toml"),
|
||||
"id='nada'\nlabel='Nada'\ncategory='ruway'\n[launch]\nexec='nada'",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.join("roto.toml"), "no es toml válido = =").unwrap();
|
||||
|
||||
let reg = AppRegistry::from_dir(&dir);
|
||||
assert_eq!(reg.len(), 2);
|
||||
assert_eq!(reg.all()[0].id, "cosmos");
|
||||
assert_eq!(reg.get("nada").unwrap().label, "Nada");
|
||||
assert_eq!(reg.handlers_for("x/chart").len(), 1);
|
||||
assert_eq!(reg.in_category("yachay").len(), 1);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifiestos_de_ejemplo_parsean_y_resuelven_handlers() {
|
||||
// Los manifiestos de `assets/apps/` (las apps reales de la suite que se
|
||||
// copian a ~/.config/gioser/apps/) deben parsear y declarar sus mimes.
|
||||
// Canario del formato: si cambia el esquema, esto avisa.
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/apps");
|
||||
let reg = AppRegistry::from_dir(&dir);
|
||||
assert_eq!(reg.len(), 2, "media + nada");
|
||||
// media abre video/audio; nada, texto/código.
|
||||
assert_eq!(reg.handlers_for("video/mp4")[0].id, "media");
|
||||
assert_eq!(reg.handlers_for("text/x-rust")[0].id, "nada");
|
||||
// El exec lleva el placeholder freedesktop.
|
||||
let media = reg.get("media").unwrap();
|
||||
assert_eq!(
|
||||
media.launch,
|
||||
Launch::Exec {
|
||||
program: "media-app".into(),
|
||||
args: vec!["%f".into()],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_estandar_y_builder() {
|
||||
let m = AppMenu::standard();
|
||||
assert_eq!(m.menus.len(), 3);
|
||||
assert_eq!(m.menus[0].label, "Archivo");
|
||||
let custom = AppMenu::new().menu(
|
||||
Menu::new("Carta").item(MenuItem::new("Duplicar", "carta.dup").shortcut("Ctrl+D")),
|
||||
);
|
||||
assert_eq!(custom.menus[0].items[0].command, "carta.dup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_roundtrip_serde() {
|
||||
let m = AppMenu::standard();
|
||||
let json = serde_json::to_string(&m).unwrap();
|
||||
let back: AppMenu = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(m, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_launcher_unsupported_para_action() {
|
||||
// Action no es del host → Unsupported (no intenta spawnear).
|
||||
let app = AppEntry {
|
||||
id: "s".into(),
|
||||
label: "Shell".into(),
|
||||
icon: None,
|
||||
category: None,
|
||||
launch: Launch::Action("focus:shell".into()),
|
||||
handles: Vec::new(),
|
||||
};
|
||||
assert_eq!(ProcessLauncher.launch(&app), Err(LaunchError::Unsupported));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bus_fanout_y_poda() {
|
||||
let bus = Bus::new();
|
||||
let a = bus.subscribe();
|
||||
let b = bus.subscribe();
|
||||
let n = bus.publish(BusEvent::LaunchRequested {
|
||||
app_id: "cosmos".into(),
|
||||
});
|
||||
assert_eq!(n, 2);
|
||||
assert!(matches!(a.recv().unwrap(), BusEvent::LaunchRequested { .. }));
|
||||
assert!(matches!(b.recv().unwrap(), BusEvent::LaunchRequested { .. }));
|
||||
drop(a);
|
||||
let n = bus.publish(BusEvent::AppFocused {
|
||||
app_id: "nada".into(),
|
||||
});
|
||||
assert_eq!(n, 1);
|
||||
}
|
||||
|
||||
// ===== open-with out-of-process =====
|
||||
|
||||
#[test]
|
||||
fn expand_target_sustituye_placeholder() {
|
||||
let args = vec!["--open".to_string(), "%f".to_string()];
|
||||
assert_eq!(
|
||||
expand_target(&args, "/tmp/x.png"),
|
||||
vec!["--open".to_string(), "/tmp/x.png".to_string()]
|
||||
);
|
||||
// `%u` también; y substitución embebida en un arg compuesto.
|
||||
let args = vec!["url=%u".to_string()];
|
||||
assert_eq!(expand_target(&args, "http://a"), vec!["url=http://a".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_target_agrega_si_no_hay_placeholder() {
|
||||
let args = vec!["--flag".to_string()];
|
||||
assert_eq!(
|
||||
expand_target(&args, "/tmp/x.png"),
|
||||
vec!["--flag".to_string(), "/tmp/x.png".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_with_sin_handler_devuelve_none() {
|
||||
let reg = AppRegistry::new(vec![]);
|
||||
assert!(reg.open_with("image/png", "/tmp/x.png").unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_with_spawnea_handler_y_le_pasa_el_target() {
|
||||
use std::io::Read;
|
||||
// Archivo donde el "handler" escribirá el target que recibió.
|
||||
let out =
|
||||
std::env::temp_dir().join(format!("app-bus-openwith-{}.txt", std::process::id()));
|
||||
let _ = std::fs::remove_file(&out);
|
||||
|
||||
// Handler = sh que escribe $1 (el target expandido en %f) al archivo.
|
||||
let entry = AppEntry {
|
||||
id: "writer".into(),
|
||||
label: "Writer".into(),
|
||||
icon: None,
|
||||
category: None,
|
||||
launch: Launch::Exec {
|
||||
program: "sh".into(),
|
||||
args: vec![
|
||||
"-c".into(),
|
||||
format!("printf '%s' \"$1\" > {}", out.display()),
|
||||
"_".into(),
|
||||
"%f".into(),
|
||||
],
|
||||
},
|
||||
handles: vec!["image/png".into()],
|
||||
};
|
||||
let reg = AppRegistry::new(vec![entry]);
|
||||
|
||||
let (chosen, child) = reg
|
||||
.open_with("image/png", "TARGET-123")
|
||||
.unwrap()
|
||||
.expect("debe haber handler para image/png");
|
||||
assert_eq!(chosen.id, "writer");
|
||||
child.expect("Exec debe spawnear un Child").wait().unwrap();
|
||||
|
||||
let mut s = String::new();
|
||||
std::fs::File::open(&out)
|
||||
.unwrap()
|
||||
.read_to_string(&mut s)
|
||||
.unwrap();
|
||||
assert_eq!(s, "TARGET-123", "el handler recibió el target en %f");
|
||||
let _ = std::fs::remove_file(&out);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "auth-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "auth-core — autenticación del escritorio: contrato Authenticator agnóstico + backend PAM + mock. Lo consume el greeter de carmen (mirada)."
|
||||
|
||||
[dependencies]
|
||||
nix = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
pam = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
rpassword = { workspace = true }
|
||||
@@ -0,0 +1,52 @@
|
||||
# auth-core
|
||||
|
||||
Autenticación del escritorio. Contrato `Authenticator` agnóstico del
|
||||
backend, con dos implementaciones.
|
||||
|
||||
## Para qué
|
||||
|
||||
El greeter de carmen (mirada) necesita verificar la contraseña del
|
||||
usuario y, en éxito, saber su `uid/gid/home/shell` para arrancar la
|
||||
sesión. Eso es exactamente lo que entrega `Authenticator::authenticate`:
|
||||
|
||||
```rust
|
||||
use brahman_auth::{Authenticator, PamAuthenticator};
|
||||
|
||||
let auth = PamAuthenticator::carmen();
|
||||
match auth.authenticate("sergio", &password) {
|
||||
Ok(info) => arrancar_sesion(info), // info: UserInfo
|
||||
Err(e) => mostrar_error_en_greeter(e),
|
||||
}
|
||||
```
|
||||
|
||||
## Backends
|
||||
|
||||
- **`PamAuthenticator`** — verifica contra PAM (`/etc/pam.d/<servicio>`),
|
||||
el mismo subsistema de `login` y `sudo`. Hereda lo que el
|
||||
administrador configure ahí (2FA, FIDO2, `pam_faillock`…) sin que el
|
||||
crate lo sepa.
|
||||
- **`MockAuthenticator`** — credenciales fijas en memoria. Para tests y
|
||||
para iterar el greeter en cajas sin PAM configurado.
|
||||
|
||||
`AuthError` es deliberadamente grueso: el greeter sólo distingue
|
||||
"reintentá" (`BadCredentials`) de "cuenta vetada" (`AccountUnavailable`),
|
||||
y nunca puede saber si un usuario existe.
|
||||
|
||||
## Servicio PAM
|
||||
|
||||
`data/carmen` es el archivo de servicio. Instalarlo:
|
||||
|
||||
```sh
|
||||
install -Dm644 data/carmen /etc/pam.d/carmen
|
||||
```
|
||||
|
||||
Ajustar el `include` a la pila de login de la distribución (ver los
|
||||
comentarios del archivo).
|
||||
|
||||
## Probar contra PAM en una máquina real
|
||||
|
||||
```sh
|
||||
cargo run -p auth-core --example auth-probe -- "$USER" login
|
||||
```
|
||||
|
||||
Pide la contraseña sin eco e informa el `UserInfo` resuelto.
|
||||
@@ -0,0 +1,15 @@
|
||||
#%PAM-1.0
|
||||
#
|
||||
# Servicio PAM del greeter de carmen (mirada). Instalar como
|
||||
# /etc/pam.d/carmen.
|
||||
#
|
||||
# El `include` apunta a la pila de login de la distribución; ajustar
|
||||
# según corresponda:
|
||||
# Arch system-login
|
||||
# Debian/Ubuntu common-auth / common-account / ... (una por línea)
|
||||
# Fedora/RHEL system-auth + postlogin
|
||||
#
|
||||
auth include system-login
|
||||
account include system-login
|
||||
password include system-login
|
||||
session include system-login
|
||||
@@ -0,0 +1,41 @@
|
||||
//! Prueba interactiva de `brahman-auth` contra PAM. Sirve para verificar
|
||||
//! la configuración de `/etc/pam.d/<servicio>` en una máquina real.
|
||||
//!
|
||||
//! `cargo run -p brahman-auth --example auth-probe -- [usuario] [servicio]`
|
||||
//!
|
||||
//! Pide la contraseña sin eco. El servicio por defecto es `carmen`; si
|
||||
//! `/etc/pam.d/carmen` aún no está instalado, probar con `login`.
|
||||
|
||||
use auth_core::{Authenticator, PamAuthenticator};
|
||||
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let user = args
|
||||
.next()
|
||||
.or_else(|| std::env::var("USER").ok())
|
||||
.unwrap_or_else(|| "root".into());
|
||||
let service = args.next().unwrap_or_else(|| "carmen".into());
|
||||
|
||||
let password = match rpassword::prompt_password(format!("Contraseña de {user}: ")) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("no se pudo leer la contraseña: {e}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
|
||||
let auth = PamAuthenticator::new(&service);
|
||||
println!("autenticando «{user}» contra el servicio PAM «{service}»…");
|
||||
match auth.authenticate(&user, &password) {
|
||||
Ok(info) => {
|
||||
println!("✓ autenticado");
|
||||
println!(" uid={} gid={}", info.uid, info.gid);
|
||||
println!(" home={}", info.home.display());
|
||||
println!(" shell={}", info.shell.display());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("✗ {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! `brahman-auth` — autenticación del escritorio.
|
||||
//!
|
||||
//! Contrato [`Authenticator`] agnóstico del backend, con dos
|
||||
//! implementaciones:
|
||||
//!
|
||||
//! - [`PamAuthenticator`] — el camino real: verifica contra PAM
|
||||
//! (`/etc/pam.d/<servicio>`), el mismo subsistema que usan `login`,
|
||||
//! `sudo` y los gestores de login clásicos. Hereda lo que el
|
||||
//! administrador configure ahí (2FA, llaves FIDO2, `pam_faillock`…)
|
||||
//! sin que `brahman-auth` tenga que saberlo.
|
||||
//! - [`MockAuthenticator`] — credenciales fijas en memoria, para tests
|
||||
//! y para iterar el greeter en cajas sin PAM configurado.
|
||||
//!
|
||||
//! Lo consume el greeter de carmen (mirada): el usuario teclea su
|
||||
//! contraseña, el greeter llama a [`Authenticator::authenticate`], y en
|
||||
//! éxito recibe un [`UserInfo`] con uid/gid/home/shell — lo que el
|
||||
//! compositor necesita para arrancar la sesión.
|
||||
|
||||
mod pam_backend;
|
||||
mod ticket;
|
||||
mod user;
|
||||
|
||||
pub use pam_backend::{PamAuthenticator, DEFAULT_SERVICE};
|
||||
pub use ticket::{SessionTicket, TICKET_TAG};
|
||||
pub use user::{resolve_user, UserInfo};
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Por qué falló una autenticación. Variantes deliberadamente gruesas:
|
||||
/// el greeter sólo necesita saber si conviene reintentar (problema de
|
||||
/// credenciales) o si la cuenta está vetada — y nunca debe poder
|
||||
/// distinguir "usuario inexistente" de "contraseña errada".
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum AuthError {
|
||||
/// Usuario o contraseña incorrectos. El greeter deja reintentar sin
|
||||
/// revelar cuál de los dos falló.
|
||||
#[error("usuario o contraseña incorrectos")]
|
||||
BadCredentials,
|
||||
|
||||
/// Las credenciales son válidas pero la cuenta está deshabilitada,
|
||||
/// expirada o requiere una acción (cambio de contraseña).
|
||||
#[error("la cuenta no está disponible: {0}")]
|
||||
AccountUnavailable(String),
|
||||
|
||||
/// Fallo del subsistema PAM no atribuible a las credenciales
|
||||
/// (servicio mal configurado, módulo roto, etc.).
|
||||
#[error("fallo de PAM: {0}")]
|
||||
Pam(String),
|
||||
|
||||
/// No se pudo resolver la identidad del usuario en el sistema tras
|
||||
/// una autenticación válida (caso raro: `/etc/passwd` inconsistente).
|
||||
#[error("no se pudo resolver el usuario «{0}» en el sistema")]
|
||||
UnresolvedUser(String),
|
||||
}
|
||||
|
||||
/// Verifica credenciales y, en éxito, entrega la identidad del sistema.
|
||||
///
|
||||
/// `&self`: cada llamada es un intento de login independiente. Las
|
||||
/// implementaciones crean su propio estado por intento — PAM exige un
|
||||
/// handle nuevo por transacción, reusarlo entre intentos es un bug.
|
||||
pub trait Authenticator {
|
||||
fn authenticate(&self, username: &str, secret: &str) -> Result<UserInfo, AuthError>;
|
||||
}
|
||||
|
||||
/// Autenticador de credenciales fijas en memoria. No toca PAM: sirve
|
||||
/// para tests y para iterar el greeter en cajas headless sin
|
||||
/// `/etc/pam.d` configurado.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct MockAuthenticator {
|
||||
creds: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl MockAuthenticator {
|
||||
/// Crea un autenticador sin usuarios: todo intento falla con
|
||||
/// [`AuthError::BadCredentials`] hasta registrar alguno.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Registra un par usuario/secreto aceptado. Encadenable.
|
||||
pub fn with_user(mut self, username: &str, secret: &str) -> Self {
|
||||
self.creds.insert(username.to_string(), secret.to_string());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Authenticator for MockAuthenticator {
|
||||
fn authenticate(&self, username: &str, secret: &str) -> Result<UserInfo, AuthError> {
|
||||
// Mismo error para usuario inexistente y para contraseña errada:
|
||||
// no filtra la existencia de cuentas.
|
||||
match self.creds.get(username) {
|
||||
Some(expected) if expected == secret => {
|
||||
// Si el usuario existe en el SO, info real; sino,
|
||||
// sintética (suficiente para tests y dev headless).
|
||||
Ok(resolve_user(username).unwrap_or_else(|_| UserInfo::synthetic(username)))
|
||||
}
|
||||
_ => Err(AuthError::BadCredentials),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mock_accepts_registered_user() {
|
||||
let auth = MockAuthenticator::new().with_user("sergio", "clave");
|
||||
let info = auth.authenticate("sergio", "clave").expect("debe pasar");
|
||||
assert_eq!(info.name, "sergio");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_rejects_wrong_password() {
|
||||
let auth = MockAuthenticator::new().with_user("sergio", "clave");
|
||||
assert_eq!(
|
||||
auth.authenticate("sergio", "mala"),
|
||||
Err(AuthError::BadCredentials)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_unknown_user_indistinguishable_from_wrong_password() {
|
||||
let auth = MockAuthenticator::new().with_user("sergio", "clave");
|
||||
assert_eq!(
|
||||
auth.authenticate("nadie", "x"),
|
||||
Err(AuthError::BadCredentials)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_mock_rejects_everything() {
|
||||
assert!(MockAuthenticator::new().authenticate("root", "").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_error_is_displayable() {
|
||||
assert!(!AuthError::BadCredentials.to_string().is_empty());
|
||||
assert!(AuthError::Pam("x".into()).to_string().contains("PAM"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Backend PAM del contrato [`Authenticator`](crate::Authenticator).
|
||||
//!
|
||||
//! El módulo se llama `pam_backend` (no `pam`) para no chocar con el
|
||||
//! crate externo `pam`, del que depende.
|
||||
|
||||
use pam::{Client, PamError, PamReturnCode};
|
||||
|
||||
use crate::{resolve_user, AuthError, Authenticator, UserInfo};
|
||||
|
||||
/// Servicio PAM por defecto del escritorio carmen. Resuelve a
|
||||
/// `/etc/pam.d/carmen` — ver el archivo `data/carmen` de este crate.
|
||||
pub const DEFAULT_SERVICE: &str = "carmen";
|
||||
|
||||
/// Autentica contra PAM: el mismo subsistema de `login`/`sudo`. Honra
|
||||
/// `/etc/pam.d/<service>` — módulos, 2FA, llaves FIDO2, `pam_faillock`,
|
||||
/// lo que el administrador configure ahí, sin que `brahman-auth` lo sepa.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PamAuthenticator {
|
||||
service: String,
|
||||
}
|
||||
|
||||
impl PamAuthenticator {
|
||||
/// Autenticador para un servicio PAM concreto (`/etc/pam.d/<service>`).
|
||||
pub fn new(service: impl Into<String>) -> Self {
|
||||
Self {
|
||||
service: service.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Autenticador para el servicio por defecto del escritorio,
|
||||
/// [`DEFAULT_SERVICE`].
|
||||
pub fn carmen() -> Self {
|
||||
Self::new(DEFAULT_SERVICE)
|
||||
}
|
||||
|
||||
/// Nombre del servicio PAM que usa este autenticador.
|
||||
pub fn service(&self) -> &str {
|
||||
&self.service
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PamAuthenticator {
|
||||
fn default() -> Self {
|
||||
Self::carmen()
|
||||
}
|
||||
}
|
||||
|
||||
impl Authenticator for PamAuthenticator {
|
||||
fn authenticate(&self, username: &str, secret: &str) -> Result<UserInfo, AuthError> {
|
||||
// Un handle PAM nuevo por intento: PAM es stateful por
|
||||
// transacción y reusar el handle entre intentos es un bug. El
|
||||
// `Client` cierra la transacción (`pam_end`) en su `Drop`.
|
||||
let mut client = Client::with_password(&self.service)
|
||||
.map_err(|e| AuthError::Pam(format!("pam_start({}): {e}", self.service)))?;
|
||||
client.conversation_mut().set_credentials(username, secret);
|
||||
|
||||
// `authenticate()` del crate hace pam_authenticate + pam_acct_mgmt:
|
||||
// cubre credenciales Y estado de la cuenta en un solo paso.
|
||||
client.authenticate().map_err(map_pam_error)?;
|
||||
|
||||
// Credenciales válidas: resolvemos la identidad del sistema.
|
||||
resolve_user(username)
|
||||
}
|
||||
}
|
||||
|
||||
/// Traduce un error de PAM a la taxonomía gruesa de [`AuthError`].
|
||||
fn map_pam_error(err: PamError) -> AuthError {
|
||||
match err.0 {
|
||||
// Credenciales: el greeter debe dejar reintentar.
|
||||
PamReturnCode::Auth_Err
|
||||
| PamReturnCode::User_Unknown
|
||||
| PamReturnCode::Cred_Insufficient
|
||||
| PamReturnCode::MaxTries => AuthError::BadCredentials,
|
||||
|
||||
// Cuenta válida pero vetada o que requiere una acción.
|
||||
PamReturnCode::Acct_Expired => AuthError::AccountUnavailable("la cuenta expiró".into()),
|
||||
PamReturnCode::Cred_Expired => {
|
||||
AuthError::AccountUnavailable("las credenciales expiraron".into())
|
||||
}
|
||||
PamReturnCode::AuthTok_Expired => {
|
||||
AuthError::AccountUnavailable("la contraseña expiró".into())
|
||||
}
|
||||
PamReturnCode::New_Authtok_Reqd => {
|
||||
AuthError::AccountUnavailable("requiere cambiar la contraseña".into())
|
||||
}
|
||||
PamReturnCode::Perm_Denied => {
|
||||
AuthError::AccountUnavailable("acceso denegado por política".into())
|
||||
}
|
||||
|
||||
// Todo lo demás: fallo de infraestructura PAM.
|
||||
other => AuthError::Pam(format!("{other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn carmen_uses_default_service() {
|
||||
assert_eq!(PamAuthenticator::carmen().service(), DEFAULT_SERVICE);
|
||||
assert_eq!(PamAuthenticator::default().service(), "carmen");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_service_name() {
|
||||
assert_eq!(PamAuthenticator::new("login").service(), "login");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_service_fails_gracefully() {
|
||||
// Sin `/etc/pam.d/<servicio>` PAM cae a `other` (deny). Debe
|
||||
// devolver un `AuthError`, nunca paniquear.
|
||||
let auth = PamAuthenticator::new("brahman-auth-servicio-inexistente-xyz");
|
||||
assert!(
|
||||
auth.authenticate("root", "contraseña-cualquiera").is_err(),
|
||||
"un servicio inexistente debe fallar limpio"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! El tiquet de sesión: lo que el greeter le entrega al compositor tras
|
||||
//! una autenticación exitosa.
|
||||
//!
|
||||
//! El greeter de carmen corre como proceso hijo del compositor. Cuando
|
||||
//! el login tiene éxito, imprime **una línea** de tiquet a su stdout; el
|
||||
//! compositor escanea las líneas del hijo buscando el prefijo
|
||||
//! [`TICKET_TAG`] y, al encontrarlo, hace el traspaso a modo sesión.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::UserInfo;
|
||||
|
||||
/// Etiqueta + versión de la línea de tiquet. El compositor sólo trata
|
||||
/// como tiquet las líneas que empiezan con esto — el resto del stdout
|
||||
/// del greeter (logs, ruido) se ignora.
|
||||
pub const TICKET_TAG: &str = "MIRADA-SESSION-TICKET-v1";
|
||||
|
||||
/// Resultado de un login: la identidad autenticada más, opcionalmente,
|
||||
/// el comando de sesión elegido. El greeter lo produce; el compositor lo
|
||||
/// consume para arrancar la sesión (setuid al usuario + spawn).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SessionTicket {
|
||||
/// Identidad del usuario autenticado.
|
||||
pub user: UserInfo,
|
||||
/// Comando de sesión a ejecutar como el usuario. Vacío = que el
|
||||
/// compositor decida (su autostart por defecto).
|
||||
pub session: String,
|
||||
/// `true` si la sesión es un compositor **ajeno** (sway, Plasma…): el
|
||||
/// servidor actual debe soltar el DRM y hacer `exec`, no correrla como
|
||||
/// cliente. `false` para sesiones nativas de mirada (pata, autostart),
|
||||
/// que sí corren como clientes del mismo compositor.
|
||||
pub foreign: bool,
|
||||
}
|
||||
|
||||
impl SessionTicket {
|
||||
/// Crea un tiquet sin comando de sesión explícito.
|
||||
pub fn new(user: UserInfo) -> Self {
|
||||
Self {
|
||||
user,
|
||||
session: String::new(),
|
||||
foreign: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fija el comando de sesión. Encadenable.
|
||||
pub fn with_session(mut self, session: impl Into<String>) -> Self {
|
||||
self.session = session.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Marca la sesión como compositor ajeno (handoff por `exec`).
|
||||
/// Encadenable.
|
||||
pub fn foreign(mut self, foreign: bool) -> Self {
|
||||
self.foreign = foreign;
|
||||
self
|
||||
}
|
||||
|
||||
/// Serializa el tiquet a una línea única, apta para stdout. Campos
|
||||
/// separados por tabulador: ni los nombres de usuario, ni los paths,
|
||||
/// ni los comandos de sesión suelen contener tabuladores.
|
||||
pub fn to_line(&self) -> String {
|
||||
format!(
|
||||
"{TICKET_TAG}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
|
||||
self.user.name,
|
||||
self.user.uid,
|
||||
self.user.gid,
|
||||
self.user.home.display(),
|
||||
self.user.shell.display(),
|
||||
self.session,
|
||||
if self.foreign { "1" } else { "0" },
|
||||
)
|
||||
}
|
||||
|
||||
/// Parsea una línea producida por [`to_line`]. `None` si la línea no
|
||||
/// es un tiquet (otra salida del greeter) o está malformada.
|
||||
pub fn from_line(line: &str) -> Option<SessionTicket> {
|
||||
let mut f = line.trim_end_matches(['\r', '\n']).split('\t');
|
||||
if f.next()? != TICKET_TAG {
|
||||
return None;
|
||||
}
|
||||
let name = f.next()?.to_string();
|
||||
let uid = f.next()?.parse().ok()?;
|
||||
let gid = f.next()?.parse().ok()?;
|
||||
let home = PathBuf::from(f.next()?);
|
||||
let shell = PathBuf::from(f.next()?);
|
||||
// El comando de sesión puede venir vacío.
|
||||
let session = f.next().unwrap_or("").to_string();
|
||||
// El flag `foreign` es opcional (tiquets viejos no lo traen).
|
||||
let foreign = matches!(f.next(), Some("1"));
|
||||
Some(SessionTicket {
|
||||
user: UserInfo {
|
||||
name,
|
||||
uid,
|
||||
gid,
|
||||
home,
|
||||
shell,
|
||||
},
|
||||
session,
|
||||
foreign,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample() -> UserInfo {
|
||||
UserInfo {
|
||||
name: "sergio".into(),
|
||||
uid: 1000,
|
||||
gid: 1000,
|
||||
home: PathBuf::from("/home/sergio"),
|
||||
shell: PathBuf::from("/usr/bin/bash"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_without_session() {
|
||||
let t = SessionTicket::new(sample());
|
||||
let back = SessionTicket::from_line(&t.to_line()).expect("parsea");
|
||||
assert_eq!(back, t);
|
||||
assert!(back.session.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_with_session() {
|
||||
let t = SessionTicket::new(sample()).with_session("shuma-shell --launcher");
|
||||
let back = SessionTicket::from_line(&t.to_line()).expect("parsea");
|
||||
assert_eq!(back, t);
|
||||
assert_eq!(back.session, "shuma-shell --launcher");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_with_foreign() {
|
||||
let t = SessionTicket::new(sample())
|
||||
.with_session("sway")
|
||||
.foreign(true);
|
||||
let back = SessionTicket::from_line(&t.to_line()).expect("parsea");
|
||||
assert_eq!(back, t);
|
||||
assert!(back.foreign);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_defaults_false_sin_campo() {
|
||||
// Una línea estilo v1 (sin el campo foreign) parsea con foreign=false.
|
||||
let line = format!("{TICKET_TAG}\tsergio\t1000\t1000\t/home/sergio\t/usr/bin/bash\tsway");
|
||||
let back = SessionTicket::from_line(&line).expect("parsea");
|
||||
assert!(!back.foreign);
|
||||
assert_eq!(back.session, "sway");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_line_ignores_non_ticket() {
|
||||
assert!(SessionTicket::from_line("[INFO] arrancando greeter").is_none());
|
||||
assert!(SessionTicket::from_line("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_line_rejects_malformed() {
|
||||
// Prefijo correcto pero faltan campos.
|
||||
assert!(SessionTicket::from_line(&format!("{TICKET_TAG}\tsergio")).is_none());
|
||||
// uid no numérico.
|
||||
assert!(
|
||||
SessionTicket::from_line(&format!("{TICKET_TAG}\tsergio\tXX\t1000\t/h\t/sh\t"))
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerates_trailing_newline() {
|
||||
let line = format!("{}\n", SessionTicket::new(sample()).to_line());
|
||||
assert!(SessionTicket::from_line(&line).is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Resolución de la identidad de un usuario del sistema.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::AuthError;
|
||||
|
||||
/// Identidad de un usuario en el sistema: lo que el compositor necesita
|
||||
/// para arrancar una sesión — fijar uid/gid, `cd` al home, ejecutar el
|
||||
/// shell o la sesión de escritorio.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UserInfo {
|
||||
/// Nombre de login.
|
||||
pub name: String,
|
||||
/// User ID.
|
||||
pub uid: u32,
|
||||
/// Group ID primario.
|
||||
pub gid: u32,
|
||||
/// Directorio personal.
|
||||
pub home: PathBuf,
|
||||
/// Shell de login.
|
||||
pub shell: PathBuf,
|
||||
}
|
||||
|
||||
impl UserInfo {
|
||||
/// Identidad sintética para tests y para cajas donde el usuario no
|
||||
/// está en `/etc/passwd`. **No** representa a un usuario real del SO
|
||||
/// — no usar para fijar privilegios de un proceso real.
|
||||
pub fn synthetic(name: &str) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
uid: 1000,
|
||||
gid: 1000,
|
||||
home: PathBuf::from(format!("/home/{name}")),
|
||||
shell: PathBuf::from("/bin/sh"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resuelve un usuario por nombre vía `getpwnam`. `Err` si no existe o
|
||||
/// si la consulta a `/etc/passwd` (o NSS) falla.
|
||||
pub fn resolve_user(name: &str) -> Result<UserInfo, AuthError> {
|
||||
match nix::unistd::User::from_name(name) {
|
||||
Ok(Some(u)) => Ok(UserInfo {
|
||||
name: u.name,
|
||||
uid: u.uid.as_raw(),
|
||||
gid: u.gid.as_raw(),
|
||||
home: u.dir,
|
||||
shell: u.shell,
|
||||
}),
|
||||
Ok(None) => Err(AuthError::UnresolvedUser(name.to_string())),
|
||||
Err(e) => Err(AuthError::Pam(format!("getpwnam({name}): {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolves_root() {
|
||||
// root (uid 0) existe en todo sistema Unix.
|
||||
let info = resolve_user("root").expect("root debe existir");
|
||||
assert_eq!(info.uid, 0);
|
||||
assert_eq!(info.name, "root");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_user_errs() {
|
||||
let r = resolve_user("usuario-que-no-existe-xyzzy");
|
||||
assert!(matches!(r, Err(AuthError::UnresolvedUser(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_has_home_under_slash_home() {
|
||||
let info = UserInfo::synthetic("prueba");
|
||||
assert_eq!(info.home, PathBuf::from("/home/prueba"));
|
||||
assert_eq!(info.uid, 1000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# =============================================================================
|
||||
# renaser :: format — el format del grafo de objetos en disco
|
||||
# -----------------------------------------------------------------------------
|
||||
# Nucleo `#![no_std]` COMPARTIDO: lo enlaza el kernel bare-metal (target
|
||||
# `x86_64-unknown-none`) y, por ser no_std, tambien lo compila sin friccion el
|
||||
# anfitrion `boot`. Es la unica verdad del format del grafo —tipos,
|
||||
# (de)serializacion postcard, hash BLAKE3, trazado de registros—, de modo que
|
||||
# kernel y constructor de imagen hablen exactamente el mismo idioma de disco.
|
||||
#
|
||||
# Queda EXCLUIDO del espacio de trabajo (ver el Cargo.toml raiz), como el
|
||||
# kernel: lo consume un paquete bare-metal, asi que fija sus versiones de
|
||||
# forma explicita, sin herencia del workspace.
|
||||
# =============================================================================
|
||||
|
||||
[package]
|
||||
name = "format"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MPL-2.0"
|
||||
authors = ["JL Soltech <gerencia@jlsoltech.com>"]
|
||||
description = "renaser :: format del grafo de objetos en disco — compartido kernel ↔ boot"
|
||||
|
||||
[lib]
|
||||
bench = false
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
# `serde` da el rasgo de (de)serializacion; `postcard` lo materializa en un
|
||||
# format binario compacto — el que viaja al disco. Ambos `no_std`, sobre `alloc`.
|
||||
serde = { version = "1", default-features = false, features = ["alloc", "derive"] }
|
||||
postcard = { version = "1", default-features = false, features = ["alloc"] }
|
||||
# `serde-big-array` cubre el hueco de serde con arrays mayores a 32 bytes: las
|
||||
# firmas Ed25519 (`Firma = [u8; 64]`) lo requieren. Compatible `no_std`.
|
||||
serde-big-array = { version = "0.5", default-features = false }
|
||||
# `blake3`: la funcion hash que da identidad a cada objeto. Se fuerza la
|
||||
# implementacion ESCALAR pura (`pure` + los cuatro `no_*`): el target del kernel
|
||||
# corre sin SSE, y un camino SIMD por deteccion en tiempo de ejecucion
|
||||
# ejecutaria instrucciones que la CPU, sin `CR4.OSFXSR`, rechazaria con un #UD.
|
||||
blake3 = { version = "1", default-features = false, features = [
|
||||
"pure", "no_sse2", "no_sse41", "no_avx2", "no_avx512",
|
||||
] }
|
||||
@@ -0,0 +1,36 @@
|
||||
# format — el formato nativo de gioser
|
||||
|
||||
Tipos canónicos del **DAG direccionado por contenido** (BLAKE3 + postcard),
|
||||
compartidos entre host y kernel `wawa`. `#![no_std]` — cruza la frontera al
|
||||
kernel bare-metal por `path`. Es el formato en el que TODO el suite trabaja en
|
||||
nativo (los formatos ajenos entran por `shared/foreign-*` y se convierten a
|
||||
esto).
|
||||
|
||||
## Módulos
|
||||
|
||||
- `tipos` — objetos, hashes, identidades de contenido.
|
||||
- `cable` — referencias entre objetos (aristas del DAG).
|
||||
- `firma` — firmas Ed25519 y verificación.
|
||||
- `pruebas` — pruebas de revocación de capacidades (WAWA.md §14.1.3).
|
||||
- `grafo` — construcción/recorrido del DAG.
|
||||
- `constantes` — parámetros del formato (tamaños, versiones).
|
||||
|
||||
## Estado (2026-05-31)
|
||||
|
||||
### Hecho
|
||||
- Tipos canónicos del DAG (objetos, cables, hashes) en `no_std`, validados en
|
||||
`wasm32-unknown-unknown` por `scripts/check-shared-cores.sh`.
|
||||
- Firma/verificación Ed25519 (`firma`) y pruebas de revocación (`pruebas`),
|
||||
canónicos compartidos kernel↔host para el enforcement §14.1.3.
|
||||
- `lib.rs` (2327 LOC) **dividido en módulos temáticos** (cable/firma/grafo/…).
|
||||
- Suite amplia (~52 tests).
|
||||
|
||||
### Pendiente
|
||||
- Versionado/migración del formato en disco (campo de versión existe; políticas
|
||||
de upgrade aún por definir).
|
||||
- Más cobertura de los caminos de revocación end-to-end.
|
||||
|
||||
## Lugar en el repo
|
||||
|
||||
`shared/format` — núcleo `no_std` compartido. Lo consumen apps, `agora` y el
|
||||
kernel `wawa`.
|
||||
@@ -0,0 +1,290 @@
|
||||
use super::*;
|
||||
|
||||
// =============================================================================
|
||||
// El hash y el trazado de un registro en el log
|
||||
// =============================================================================
|
||||
|
||||
/// La identidad de un objeto: el hash BLAKE3 de su forma serializada. Kernel y
|
||||
/// `boot` la calculan por aqui — una sola definicion del hash, jamas dos.
|
||||
pub fn hash(bytes: &[u8]) -> Hash {
|
||||
*blake3::hash(bytes).as_bytes()
|
||||
}
|
||||
|
||||
/// Numero de sectores que ocupa un registro cuyo payload mide `longitud`
|
||||
/// bytes. Cada registro es `[longitud: u32 LE][payload postcard][relleno 0]`.
|
||||
pub fn sectores_registro(longitud: usize) -> u64 {
|
||||
(4 + longitud).div_ceil(TAM_SECTOR) as u64
|
||||
}
|
||||
|
||||
/// Compone el registro en disco de un payload: `[longitud u32 LE][payload]
|
||||
/// [relleno a cero]`, alineado a un numero entero de sectores. Es el trazado
|
||||
/// exacto que el kernel lee al reconstruir su indice — lo escriben tanto
|
||||
/// `kernel::almacen` (al anexar un objeto) como `boot` (al sembrar la imagen).
|
||||
pub fn componer_registro(payload: &[u8]) -> Vec<u8> {
|
||||
let n = sectores_registro(payload.len()) as usize;
|
||||
let mut registro = vec![0u8; n * TAM_SECTOR];
|
||||
registro[0..4].copy_from_slice(&(payload.len() as u32).to_le_bytes());
|
||||
registro[4..4 + payload.len()].copy_from_slice(payload);
|
||||
registro
|
||||
}
|
||||
|
||||
/// Lee la cabecera de longitud de un registro (sus 4 primeros bytes). Devuelve
|
||||
/// `None` si la longitud es cero —fin del log— o supera [`MAX_OBJETO`]
|
||||
/// —corrupcion—. Gemela de [`componer_registro`].
|
||||
pub fn longitud_registro(cabecera: &[u8]) -> Option<usize> {
|
||||
if cabecera.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let longitud =
|
||||
u32::from_le_bytes([cabecera[0], cabecera[1], cabecera[2], cabecera[3]]) as usize;
|
||||
if longitud == 0 || longitud > MAX_OBJETO {
|
||||
None
|
||||
} else {
|
||||
Some(longitud)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Fase 60 — Asistente Akasha: tipos de mensaje del canal del asistente
|
||||
// -----------------------------------------------------------------------------
|
||||
// La app `asistente.wasm` (kernel-side) y el `asistente-puente` (host-side)
|
||||
// conversan por un canal Akasha bien conocido. Estos tipos definen el
|
||||
// protocolo. Diseñado para serializarse con `postcard` (el mismo encoder
|
||||
// que usa todo el resto del kernel) y vivir en `#![no_std] + alloc` para
|
||||
// cruzar la frontera kernel-wasm sin friction.
|
||||
//
|
||||
// ESTADO (Fase 60 v1): tipos definidos, sin código que los consuma todavía.
|
||||
// Ver `docs/ASISTENTE_WAWA.md` §2.2 para el contexto del diseño.
|
||||
// =============================================================================
|
||||
|
||||
/// Canal Akasha bien conocido para el asistente. ASCII `"AS"` = 0x4153. El
|
||||
/// kernel filtra paquetes con este canal hacia los suscriptores del oficio
|
||||
/// asistente; el puente Linux abre un socket raw que suscribe al mismo
|
||||
/// número para recibir consultas y enviar propuestas.
|
||||
///
|
||||
/// NOTA: 0x4153 está dentro del rango histórico de "longitud" de Ethernet
|
||||
/// (< 0x0600), así que NO sirve como EtherType. Para los frames del
|
||||
/// asistente sobre el cable se usa [`ETHERTYPE_ASISTENTE`]; este valor
|
||||
/// queda como discriminante interno (postcard tag, identificador del
|
||||
/// oficio en logs y trazas).
|
||||
pub const CANAL_ASISTENTE: u16 = 0x4153;
|
||||
|
||||
/// EtherType de los frames del asistente sobre el cable. Vecino del
|
||||
/// 0x88B5 que ya usa Akasha — los dos viven en el rango "experimental"
|
||||
/// que la IEEE deja libre. El demuxer Akasha del kernel (`akasha.rs`)
|
||||
/// trata frames con EtherType ajeno como "para el usuario": los encola
|
||||
/// tal cual sin procesar. La app `asistente.wasm` los recoge con
|
||||
/// `sys_net_recibir`, filtra por este EtherType y decodifica el payload
|
||||
/// como [`MensajeAsistente`] postcard.
|
||||
///
|
||||
/// Mantenerlo distinto de 0x88B5 evita que el demuxer intente
|
||||
/// deserializar el payload como `MensajeAkasha` y lo descarte como
|
||||
/// `PayloadInvalido` antes de pasarlo al usuario.
|
||||
pub const ETHERTYPE_ASISTENTE: u16 = 0x88B6;
|
||||
|
||||
/// Acción que el LLM (vía el puente) propone al asistente. La app pinta
|
||||
/// la propuesta, el humano decide. Acciones potentes (re-anclar manifiesto,
|
||||
/// cambiar configuración) referencian objetos del grafo por `Hash` — el
|
||||
/// puente los preparó y los ingestó vía Akasha; el kernel los verifica al
|
||||
/// aplicar.
|
||||
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
|
||||
pub enum AccionPropuesta {
|
||||
/// Lanzar la app `plantilla`-ésima del manifiesto. Equivalente al
|
||||
/// `Mando::LanzarFila` del launcher, pero dirigido por LLM.
|
||||
LanzarApp { plantilla: u32 },
|
||||
/// Re-anclar el manifiesto al hash propuesto. Requiere firma humana
|
||||
/// vía `daemon-firma` antes de invocar `sys_manifiesto_proponer`.
|
||||
InstalarApp { manifiesto_propuesto: Hash },
|
||||
/// Cambiar la `Configuracion` activa al hash propuesto. Mismo flujo
|
||||
/// de firma humana que `InstalarApp`.
|
||||
CambiarConfiguracion { config_propuesta: Hash },
|
||||
/// Sin efecto sobre el sistema — el LLM nada más anota algo para que
|
||||
/// el humano lo lea. Útil para responder preguntas tipo "¿cuántas
|
||||
/// apps tengo?" sin disparar acciones.
|
||||
Notar { texto: String },
|
||||
}
|
||||
|
||||
/// Contexto del estado actual del nodo wawa que la app envía al puente
|
||||
/// junto con la consulta. Permite que el LLM responda con info concreta
|
||||
/// (nombres de apps reales, configuración activa) en lugar de a ciegas.
|
||||
/// Lo que se incluye está acotado deliberadamente — más campos = más
|
||||
/// tokens en el system prompt = más coste.
|
||||
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug, Default)]
|
||||
pub struct Contexto {
|
||||
/// Nombres de las apps del manifiesto vivo, en el orden del catálogo
|
||||
/// del launcher. El LLM puede usar `LanzarApp { plantilla: i }` con
|
||||
/// el índice de la fila correspondiente.
|
||||
pub apps: Vec<String>,
|
||||
/// Hash del manifiesto vigente. Permite que el puente detecte si su
|
||||
/// caché local quedó stale (otro nodo re-ancló en paralelo) y
|
||||
/// rerequiera contexto fresco.
|
||||
pub manifiesto_actual: Option<Hash>,
|
||||
/// Hash de la `Configuracion` activa, si la hay. `None` si el
|
||||
/// manifiesto no enlaza ninguna.
|
||||
pub configuracion_activa: Option<Hash>,
|
||||
}
|
||||
|
||||
/// Un mensaje sobre el canal `CANAL_ASISTENTE`. La app y el puente
|
||||
/// hablan exclusivamente este enum — un atacante que envíe payload ajeno
|
||||
/// al canal se queda sin decodificar (postcard rechaza el frame).
|
||||
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
|
||||
pub enum MensajeAsistente {
|
||||
/// La app pregunta. El puente lo retransmite al LLM. `id` correlaciona
|
||||
/// request/response — un puente sirviendo varios nodos los distingue
|
||||
/// por id ANTES de cualquier RTT extra.
|
||||
Consulta {
|
||||
id: u64,
|
||||
prompt: String,
|
||||
contexto: Contexto,
|
||||
},
|
||||
/// El puente responde con una propuesta interpretada del LLM.
|
||||
/// `confianza` es la decisión del puente — `1.0` si el LLM produjo
|
||||
/// JSON limpio y la acción está en la lista blanca; valores menores
|
||||
/// si tuvo que adivinar o si el parseo fue parcial.
|
||||
Propuesta {
|
||||
id: u64,
|
||||
accion: AccionPropuesta,
|
||||
explicacion: String,
|
||||
confianza: f32,
|
||||
},
|
||||
/// El puente reporta un fallo de transporte o parseo. El `id`
|
||||
/// correlaciona contra la consulta original; el `motivo` es un string
|
||||
/// libre que la app pinta al humano.
|
||||
Error { id: u64, motivo: String },
|
||||
}
|
||||
|
||||
impl MensajeAsistente {
|
||||
/// Serializa con postcard. El kernel lo manda por Akasha; el puente
|
||||
/// lo recibe y deserializa.
|
||||
pub fn serializar(&self) -> Result<Vec<u8>, postcard::Error> {
|
||||
postcard::to_allocvec(self)
|
||||
}
|
||||
|
||||
/// Deserializa desde bytes. Si el frame está truncado o el canal
|
||||
/// trajo basura ajena, devuelve error sin tocar `self`.
|
||||
pub fn deserializar(bytes: &[u8]) -> Result<Self, postcard::Error> {
|
||||
postcard::from_bytes(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Protocolo "cable" del asistente — alfabeto minimo sin alloc
|
||||
// -----------------------------------------------------------------------------
|
||||
// `MensajeAsistente` (arriba) usa `String` y `Vec` para empaquetar prompts
|
||||
// y explicaciones de longitud arbitraria. La app `asistente.wasm` corre en
|
||||
// no_std SIN alloc — no puede construir esos tipos. Para el cable definimos
|
||||
// un alfabeto minimo que cabe en arrays fijos: cabecera de 12 bytes
|
||||
// (canal + tipo + id) + payload de longitud inferida del frame.
|
||||
//
|
||||
// El puente Linux traduce entre el rico `MensajeAsistente` (que usa para
|
||||
// hablar con pluma-llm) y este protocolo cable (que viaja por Akasha).
|
||||
// =============================================================================
|
||||
|
||||
/// Tamaño en bytes de la cabecera del protocolo cable.
|
||||
/// `canal (2) + tipo (2) + id (8) = 12`.
|
||||
pub const TAM_CABECERA_CABLE: usize = 12;
|
||||
|
||||
/// Tipos de mensaje sobre el cable del asistente. Discriminante u16 big
|
||||
/// endian estable — los lectores binarios pueden grep por estos valores.
|
||||
#[repr(u16)]
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum TipoCable {
|
||||
/// Consulta de la app al puente. Payload = bytes ASCII del prompt
|
||||
/// (sin nul terminator — la longitud se infiere del frame).
|
||||
Consulta = 1,
|
||||
/// Propuesta del puente del tipo `Notar` (la IA contestó algo
|
||||
/// informativo). Payload = bytes ASCII del texto.
|
||||
PropuestaNotar = 2,
|
||||
/// Propuesta del puente del tipo `LanzarApp`. Payload = u32 BE con
|
||||
/// el índice de plantilla a lanzar (4 bytes).
|
||||
PropuestaLanzarApp = 3,
|
||||
/// Propuesta de re-anclar el manifiesto. Payload = 32 bytes del hash.
|
||||
PropuestaInstalarApp = 4,
|
||||
/// Propuesta de cambiar la configuración activa. Payload = 32 bytes
|
||||
/// del hash de la nueva configuración.
|
||||
PropuestaCambiarConfig = 5,
|
||||
/// Error reportado por el puente (transporte, rechazo del LLM,
|
||||
/// parseo). Payload = bytes ASCII del motivo.
|
||||
Error = 6,
|
||||
/// Fase 60 v4 :: la app `asistente.wasm` pide la firma humana de un
|
||||
/// objeto (manifiesto/configuración). El puente lo relaya al
|
||||
/// `wawactl daemon-firma` por su transporte normal (PTY/virtio-console)
|
||||
/// y devuelve la firma en un [`TipoCable::Firma`]. Payload:
|
||||
/// `[tipo_obj: u8, hash: [u8; 32]]` = 33 bytes.
|
||||
/// - `tipo_obj` = [`TIPO_OBJETO_CUADERNO`] (1) si el hash es de
|
||||
/// manifiesto/cuaderno (legacy `wawa::sign_request::`).
|
||||
/// - `tipo_obj` = [`TIPO_OBJETO_CONFIGURACION`] (2) si es de
|
||||
/// configuración (`wawa::sign_config::`).
|
||||
/// Otros valores son rechazados por el puente con un `TipoCable::Error`.
|
||||
RequestFirma = 7,
|
||||
/// Fase 60 v4 :: respuesta del puente con la firma humana ya
|
||||
/// autorizada por el operador (via `daemon-firma`). Payload:
|
||||
/// `[slot: u8, firma: [u8; 64]]` = 65 bytes. `slot` es 0/1/2 — el
|
||||
/// índice dentro de `AGORA_AUTH_RING` que el operador eligió al
|
||||
/// arrancar el demonio. El asistente.wasm construye el sobre
|
||||
/// firmado y, cuando tenga PERMISO_RAIZ (hito 6), invoca
|
||||
/// `sys_manifiesto_proponer`.
|
||||
Firma = 8,
|
||||
}
|
||||
|
||||
/// FASE 60 v4 :: discriminantes del primer byte del payload de
|
||||
/// `TipoCable::RequestFirma`. El puente los mapea al prefijo correcto
|
||||
/// para `daemon-firma` (`wawa::sign_request::` vs `wawa::sign_config::`).
|
||||
/// El mismo discriminante puede aparecer en logs del operador.
|
||||
pub const TIPO_OBJETO_CUADERNO: u8 = 1;
|
||||
/// Como [`TIPO_OBJETO_CUADERNO`] pero para configuraciones. Ver Fase 60 v2
|
||||
/// del `wawactl daemon-firma` — el prefijo correspondiente es
|
||||
/// `wawa::sign_config::`.
|
||||
pub const TIPO_OBJETO_CONFIGURACION: u8 = 2;
|
||||
|
||||
impl TipoCable {
|
||||
/// Traduce un u16 al variant correspondiente o `None` si es
|
||||
/// desconocido (el cable trajo un tipo no registrado).
|
||||
pub fn de_u16(v: u16) -> Option<Self> {
|
||||
match v {
|
||||
1 => Some(Self::Consulta),
|
||||
2 => Some(Self::PropuestaNotar),
|
||||
3 => Some(Self::PropuestaLanzarApp),
|
||||
4 => Some(Self::PropuestaInstalarApp),
|
||||
5 => Some(Self::PropuestaCambiarConfig),
|
||||
6 => Some(Self::Error),
|
||||
7 => Some(Self::RequestFirma),
|
||||
8 => Some(Self::Firma),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Escribe la cabecera del cable en `out`. Devuelve la longitud escrita
|
||||
/// (siempre `TAM_CABECERA_CABLE`) o `None` si `out` no cabe — el caller
|
||||
/// reserva el buffer apropiado.
|
||||
pub fn escribir_cabecera_cable(out: &mut [u8], tipo: TipoCable, id: u64) -> Option<usize> {
|
||||
if out.len() < TAM_CABECERA_CABLE {
|
||||
return None;
|
||||
}
|
||||
out[0..2].copy_from_slice(&CANAL_ASISTENTE.to_be_bytes());
|
||||
out[2..4].copy_from_slice(&(tipo as u16).to_be_bytes());
|
||||
out[4..12].copy_from_slice(&id.to_be_bytes());
|
||||
Some(TAM_CABECERA_CABLE)
|
||||
}
|
||||
|
||||
/// Lee la cabecera del cable y verifica que el canal sea el del
|
||||
/// asistente. Devuelve `(tipo, id)` o `None` si los bytes son
|
||||
/// insuficientes, el canal no coincide o el tipo es desconocido. El
|
||||
/// llamante interpreta `&bytes[TAM_CABECERA_CABLE..]` según `tipo`.
|
||||
pub fn leer_cabecera_cable(bytes: &[u8]) -> Option<(TipoCable, u64)> {
|
||||
if bytes.len() < TAM_CABECERA_CABLE {
|
||||
return None;
|
||||
}
|
||||
let canal = u16::from_be_bytes([bytes[0], bytes[1]]);
|
||||
if canal != CANAL_ASISTENTE {
|
||||
return None;
|
||||
}
|
||||
let tipo_raw = u16::from_be_bytes([bytes[2], bytes[3]]);
|
||||
let tipo = TipoCable::de_u16(tipo_raw)?;
|
||||
let id = u64::from_be_bytes([
|
||||
bytes[4], bytes[5], bytes[6], bytes[7], bytes[8], bytes[9], bytes[10], bytes[11],
|
||||
]);
|
||||
Some((tipo, id))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// =============================================================================
|
||||
// Constantes del format en disco
|
||||
// =============================================================================
|
||||
|
||||
/// Firma magica del superbloque — «RENASer GRaFo». Distingue un disco de
|
||||
/// renaser de uno virgen o ajeno.
|
||||
pub const MAGIA: [u8; 8] = *b"RENASGRF";
|
||||
|
||||
/// Version del format del superbloque en disco. Un disco con otra version se
|
||||
/// reformatea al arrancar. v3 (Fase 24) — el superbloque porta `log_inicio`:
|
||||
/// el sector donde arranca el log activo. El compactador semantico copia el
|
||||
/// set alcanzable a una zona limpia del disco y reanca el superbloque a un
|
||||
/// nuevo `log_inicio` en una sola escritura atomica. v2 (Fase 7) ya portaba
|
||||
/// el ancla `manifiesto`, gemela de `raiz`.
|
||||
pub const VERSION_SUPERBLOQUE: u32 = 3;
|
||||
|
||||
/// Version del format del manifiesto serializado. Independiente de la del
|
||||
/// superbloque: el manifiesto es un objeto del grafo, no una estructura fija
|
||||
/// del disco. v4 — cada `EntradaApp` declara su `permisos: u32`: un bitfield
|
||||
/// que dicta QUE capacidades el kernel enlaza en su `Linker` de wasmi. Las
|
||||
/// capacidades sensibles (red, raiz, altavoz, configuracion, escritura del
|
||||
/// grafo) no se REGISTRAN si el bit no esta puesto: la frontera es fisica,
|
||||
/// no chequeada en runtime. No hay escalada porque no hay tabla que escalar.
|
||||
///
|
||||
/// v5 (Fase 67 / WAWA §14.1.3) — cada `EntradaApp` gana `concesion:
|
||||
/// Option<Hash>`: el hash de una [`ConcesionCapacidad`] firmada por el
|
||||
/// `AGORA_AUTH_RING` sobre `(bytecode, permisos)`. Cuando una app la declara,
|
||||
/// el kernel toma la INTERSECCION [`permisos_efectivos`]`(declarados,
|
||||
/// concedidos)` — un manifiesto re-firmado ya no puede escalar un binario mas
|
||||
/// alla de lo que su concesion, atada a su hash, autoriza. Si `concesion` es
|
||||
/// `None` no hay techo per-bytecode: gobierna la firma del manifiesto (camino
|
||||
/// legacy, rollout escalonado — ver `SDD-capacidades.md` §3.6).
|
||||
///
|
||||
/// CORTE DE FORMATO: `postcard` NO es autodescriptivo, asi que cada campo nuevo
|
||||
/// rompe el wire de la version previa. Un disco viejo NO deserializa — el guardia
|
||||
/// de version (`Manifiesto::deserializar` exige `version == VERSION_MANIFIESTO`)
|
||||
/// lo rechaza y exige re-sembrar el genesis. En la practica el operador re-forja
|
||||
/// la imagen en cada `cargo run -p boot`, asi que la genesis nace limpia.
|
||||
/// v5→v6 (2026-05-30): agrega `overlay_revocacion: Option<Hash>` para el plano de
|
||||
/// control del SDD-rotacion-revocacion §4.
|
||||
/// v6→v7 (2026-06-03): agrega `marco: Option<Hash>` para persistir el marco del
|
||||
/// escritorio (`pata`) activo entre reinicios — Fase 9 del SDD de pata.
|
||||
pub const VERSION_MANIFIESTO: u32 = 7;
|
||||
|
||||
/// Version del format de la `Configuracion` serializada. La configuracion es
|
||||
/// otro objeto del grafo (idioma + paleta); el manifiesto la enlaza por hash.
|
||||
/// v1 inaugura el modelo: cambiarla es engendrar un nodo nuevo y reanclar.
|
||||
pub const VERSION_CONFIGURACION: u32 = 1;
|
||||
|
||||
/// Version del format del canal de release serializado. Independiente del
|
||||
/// manifiesto: un canal es otro objeto del grafo, con su propia historia de
|
||||
/// raices recomendadas. v1 inaugura el modelo de distribucion.
|
||||
pub const VERSION_CANAL: u32 = 1;
|
||||
|
||||
/// Techo del nombre de un canal, en bytes. Acota la cabecera serializada y
|
||||
/// fuerza a que los canales se nombren cortos —`estable`, `beta`, `dev`,
|
||||
/// `cofradia-tal`—. Quien intente registrar un canal con un nombre mas largo
|
||||
/// se topa con un error de deserializacion.
|
||||
pub const NOMBRE_CANAL_LIMITE: usize = 64;
|
||||
|
||||
/// Techo del tamaño de un objeto serializado: 1 MiB. Acota los buferes de E/S
|
||||
/// y permite descartar un registro corrupto sin leer un disparate.
|
||||
pub const MAX_OBJETO: usize = 1024 * 1024;
|
||||
|
||||
/// Tamaño de un sector del disco, en bytes. El log se traza en multiplos de
|
||||
/// esta unidad — la misma que expone el transporte virtio-blk.
|
||||
pub const TAM_SECTOR: usize = 512;
|
||||
|
||||
/// El identificador de un objeto: el hash BLAKE3 de su forma serializada. En
|
||||
/// un almacen direccionado por contenido, la identidad ES el contenido.
|
||||
pub type Hash = [u8; 32];
|
||||
|
||||
// =============================================================================
|
||||
// CodigoError — el lenguaje de los retornos de syscall, sin alucinaciones
|
||||
// -----------------------------------------------------------------------------
|
||||
// Los retornos negativos de las capacidades `sys_*` no son enteros opacos:
|
||||
// son variantes nombradas, fuertemente tipadas, con un valor i32 estable.
|
||||
// El kernel emite `CodigoError::X as i32`; el userspace compara contra el
|
||||
// mismo numero. Anadir una variante NUEVA es engendrar un valor nuevo (las
|
||||
// existentes jamas se renumeran), de modo que un binario viejo y un kernel
|
||||
// nuevo siguen hablando el mismo idioma para los codigos que ambos conocen.
|
||||
//
|
||||
// Los retornos POSITIVOS de algunas capacidades son cuentas de bytes copiados
|
||||
// —no errores—; por eso `Ok = 0` y todos los errores caen en negativos. La
|
||||
// comparacion habitual del userspace queda intacta: `< 0` ya es "fallo", y
|
||||
// el codigo concreto lo describe.
|
||||
// =============================================================================
|
||||
|
||||
/// El catalogo de retornos negativos de las capacidades del kernel. Un solo
|
||||
/// nombre por causa: nadie ha de inventarse una semantica nueva para el -1.
|
||||
///
|
||||
/// Mantenido en `format` porque viaja por TRES fronteras: el kernel lo emite,
|
||||
/// el explorador (host-side) lo lee de las trazas, y los modulos WASM lo
|
||||
/// reciben. La crate ya es no_std y la traen ambos lados sin friccion.
|
||||
#[repr(i32)]
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum CodigoError {
|
||||
/// Operacion completada sin novedad. Las capacidades que devuelven un
|
||||
/// conteo de bytes usan tambien `0` para "no habia nada que entregar"
|
||||
/// (lectura sin frame, sin evento, sin estado previo); el contexto del
|
||||
/// retorno positivo distingue ambos casos.
|
||||
Ok = 0,
|
||||
/// El recurso solicitado no esta presente: un objeto que no esta en el
|
||||
/// grafo, la tarjeta de red sin montar, una app sin estado previo, una
|
||||
/// cola del puntero o el teclado vacia. Tambien lo emite un guardar
|
||||
/// que no encontro su ranura.
|
||||
Ausente = -1,
|
||||
/// La capacidad recibida en `salida` no cubre los datos a copiar. La app
|
||||
/// debe llamar con un bufer mas amplio; el kernel no escribio nada en el
|
||||
/// destino.
|
||||
CapacidadInsuficiente = -2,
|
||||
/// El subsistema de almacenamiento (virtio-blk, log de objetos, censo del
|
||||
/// manifiesto) fallo al servir o anclar el objeto. NO es culpa del modulo,
|
||||
/// pero la operacion no pudo completarse.
|
||||
AlmacenamientoFallo = -3,
|
||||
/// La app no tiene el FOCO del compositor en este fotograma y la capacidad
|
||||
/// solo se honra para la ventana enfocada — por ejemplo, cambiar la
|
||||
/// `Configuracion` del escritorio. Reintentar cuando la app sea la
|
||||
/// destinataria del teclado.
|
||||
SinFoco = -4,
|
||||
/// El envio al dispositivo (driver de red, altavoz) fracaso. Lo emite el
|
||||
/// driver y la capacidad lo propaga: no hay rastro de bytes residuales en
|
||||
/// el hardware.
|
||||
EnvioFallo = -5,
|
||||
/// Cuota de recurso saturada para esta app en este fotograma: hay un
|
||||
/// limite blando que protege un recurso fisico (DMA, descriptores de un
|
||||
/// anillo virtio) y la app lo alcanzo. El kernel NO entrega la
|
||||
/// operacion ni avanza el contador; la app ha de retirarse y volver a
|
||||
/// intentar en su proximo `tick` —cuando la IRQ del hardware haya
|
||||
/// liberado los descriptores que tenia retenidos—. Es BACK-PRESSURE
|
||||
/// cooperativa: el equivalente de un `Poll::Pending` que cabe en un
|
||||
/// codigo de retorno entero. Distingue a una autodefensa del kernel
|
||||
/// frente al codigo de la app de un fallo del propio almacenamiento.
|
||||
Saturado = -6,
|
||||
/// El payload que la app entrego al kernel decodifica pero esta FUERA
|
||||
/// del dominio que la capacidad acepta — un codigo de idioma que no es
|
||||
/// letras ASCII, una paleta cuyos canales suman cero, un campo
|
||||
/// inconsistente con su contexto. Distinto de `Ausente` (recurso
|
||||
/// inexistente) y `CapacidadInsuficiente` (bufer corto): aqui los
|
||||
/// bytes llegaron pero su SIGNIFICADO los descalifica. La app ha
|
||||
/// de reconstruir su entrada con valores legitimos antes de reintentar.
|
||||
PayloadInvalido = -7,
|
||||
}
|
||||
|
||||
impl CodigoError {
|
||||
/// Convierte el codigo a su forma de cable i32 — la unica que el userspace
|
||||
/// recibe. `as i32` directo, sin trampa: el `#[repr(i32)]` fija el valor.
|
||||
pub const fn como_i32(self) -> i32 {
|
||||
self as i32
|
||||
}
|
||||
}
|
||||
|
||||
/// La identidad de un autor agora: una clave publica Ed25519, 32 bytes. Quien
|
||||
/// firma una raiz de canal se identifica con esto. `format` no valida la
|
||||
/// firma —no enlaza ninguna primitiva criptografica—; solo declara su forma.
|
||||
/// La verificacion vive en `agora` (o en `firma`), donde corresponde.
|
||||
pub type AgoraId = [u8; 32];
|
||||
|
||||
/// Una firma Ed25519, 64 bytes. La produce `agora` sobre el mensaje canonico
|
||||
/// que devuelve [`mensaje_a_firmar`]. `format` la transporta y la deja a quien
|
||||
/// pueda verificarla.
|
||||
pub type Firma = [u8; 64];
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
use super::*;
|
||||
|
||||
// =============================================================================
|
||||
// Fase 67 :: la CONCESION DE CAPACIDAD — "que binario puede hacer que", firmado
|
||||
// -----------------------------------------------------------------------------
|
||||
// Hoy los permisos de una app viven en su `EntradaApp` del manifiesto: el
|
||||
// manifiesto firmado dice "el bytecode X corre con permisos P". El binding es
|
||||
// tan fuerte como el manifiesto — re-firmar un manifiesto nuevo basta para
|
||||
// darle al MISMO binario permisos distintos. La concesion eleva ese binding a
|
||||
// un hecho INDEPENDIENTE del manifiesto: una firma Ed25519 de una llave del
|
||||
// `AGORA_AUTH_RING` sobre el par `(hash_bytecode, permisos)`. La firma viaja
|
||||
// con el binario y NINGUN manifiesto puede escalar un binario mas alla de lo
|
||||
// que su concesion autoriza —el kernel toma la INTERSECCION, ver
|
||||
// [`permisos_efectivos`]—. Gemelo estructural de [`ManifiestoFirmado`]: la
|
||||
// verificacion comparte el camino Ring 0 zero-alloc de `ed25519-compact`, pero
|
||||
// el mensaje firmado es [`mensaje_capacidad`], no el hash pelado.
|
||||
// =============================================================================
|
||||
|
||||
/// Una concesion de capacidad firmada: liga inmutablemente un bytecode (por su
|
||||
/// hash BLAKE3) a un bitfield de permisos, respaldada por la firma de una
|
||||
/// identidad soberana. Es un objeto del grafo (direccionado por contenido) que
|
||||
/// un `EntradaApp` referencia; el kernel la verifica contra el `AGORA_AUTH_RING`
|
||||
/// antes de enlazar capacidad alguna.
|
||||
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
|
||||
pub struct ConcesionCapacidad {
|
||||
/// Hash BLAKE3 del objeto-bytecode WASM al que esta concesion aplica. La
|
||||
/// firma lo cubre: una concesion para el bytecode X jamas vale para Y.
|
||||
pub bytecode: Hash,
|
||||
/// Bitfield de permisos que esta concesion AUTORIZA para ese bytecode (ver
|
||||
/// [`Permisos`] y las constantes `PERMISO_*`). Subir un bit invalida la firma.
|
||||
pub permisos: Permisos,
|
||||
/// Llave publica Ed25519 de quien concede. El kernel exige que habite el
|
||||
/// `AGORA_AUTH_RING` antes de gastar un ciclo en criptografia.
|
||||
pub autor: AgoraId,
|
||||
/// Firma Ed25519 sobre [`mensaje_capacidad`]`(bytecode, permisos)`.
|
||||
#[serde(with = "BigArray")]
|
||||
pub firma: Firma,
|
||||
}
|
||||
|
||||
impl ConcesionCapacidad {
|
||||
/// Serializa la concesion a `postcard` — la carga util del objeto del grafo
|
||||
/// que la aloja.
|
||||
pub fn serializar(&self) -> Result<Vec<u8>, &'static str> {
|
||||
postcard::to_allocvec(self)
|
||||
.map_err(|_| "concesion_capacidad :: serializacion fallida")
|
||||
}
|
||||
|
||||
/// Reconstruye una concesion desde su forma binaria. Tolera bytes
|
||||
/// sobrantes tras la estructura — el relleno del registro.
|
||||
pub fn deserializar(bytes: &[u8]) -> Result<ConcesionCapacidad, &'static str> {
|
||||
postcard::take_from_bytes::<ConcesionCapacidad>(bytes)
|
||||
.map(|(c, _)| c)
|
||||
.map_err(|_| "concesion_capacidad :: deserializacion fallida")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Overlay de revocacion del plano de CONTROL (SDD-rotacion-revocacion §4)
|
||||
// -----------------------------------------------------------------------------
|
||||
// El AGORA_AUTH_RING del kernel es `const` en `.rodata`: rotar el ancla = reflash
|
||||
// deliberado. Pero entre reflasheos una clave soberana puede filtrarse, y esperar
|
||||
// al re-forjado deja una ventana abierta. El overlay la cierra: un objeto del
|
||||
// grafo, anclado por el manifiesto (`Manifiesto::overlay_revocacion`), que lista
|
||||
// revocaciones firmadas M-of-N por el RESTO del anillo. El kernel lo lee FRESH en
|
||||
// el arranque y deniega en `autor_en_anillo` toda clave del anillo revocada.
|
||||
//
|
||||
// Tipos `no_std + alloc`: el kernel los deserializa (postcard) y los verifica con
|
||||
// `claves::verificar_revocacion` sobre el canonico de `mensaje_revocacion_clave`.
|
||||
// El productor host-side (`agora-cli wawa revocar`) emite el mismo wire.
|
||||
//
|
||||
// TIEMPO: el kernel hoy lleva ticks PIT, no wall-clock. Aplica la revocacion
|
||||
// mientras este ANCLADA (fail-closed, deny-wins); `vence_en` entra en el canonico
|
||||
// firmado pero la auto-caducidad temporal espera un RTC. Des-revocar = anclar un
|
||||
// overlay nuevo sin esa entrada (gemelo de mover el puntero de `configuracion`).
|
||||
// =============================================================================
|
||||
|
||||
/// Version del format del [`OverlayRevocacion`] serializado.
|
||||
pub const VERSION_OVERLAY: u32 = 1;
|
||||
|
||||
/// Una firma individual dentro de una [`RevocacionFirmada`]: la pubkey del
|
||||
/// firmante y su firma Ed25519 sobre el canonico de la revocacion. Espejo
|
||||
/// minimo de `agora_core::SingleSig` (sin el `IdentityId` redundante: el kernel
|
||||
/// re-deriva la autoridad comparando la pubkey contra el `AGORA_AUTH_RING`).
|
||||
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
|
||||
pub struct FirmaRevocacion {
|
||||
/// Clave publica Ed25519 del firmante (un miembro del anillo, en control).
|
||||
pub autor: AgoraId,
|
||||
/// Firma Ed25519 sobre [`mensaje_revocacion_clave`]`(objetivo, motivo,
|
||||
/// emitida_en, vence_en)`.
|
||||
#[serde(with = "BigArray")]
|
||||
pub firma: Firma,
|
||||
}
|
||||
|
||||
/// Una revocacion de clave firmada por un quorum, en forma de wire para el
|
||||
/// overlay. Espejo de `agora_core::Revocation` aplanado para el kernel: el
|
||||
/// `motivo` es el discriminante estable de `RevReason` (0=Compromised,
|
||||
/// 1=Retired, 2=Superseded) y `firmantes` es la multifirma desnuda.
|
||||
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
|
||||
pub struct RevocacionFirmada {
|
||||
/// La clave que se revoca (en el plano de control, una del anillo).
|
||||
pub objetivo: AgoraId,
|
||||
/// Motivo (entra en el canonico firmado): 0=Compromised, 1=Retired, 2=Superseded.
|
||||
pub motivo: u8,
|
||||
/// Segundos UNIX desde cuando rige.
|
||||
pub emitida_en: u64,
|
||||
/// `None` ⇒ permanente; `Some(t)` ⇒ suspension hasta `t` (auto-caducidad
|
||||
/// pendiente de RTC en el kernel — ver nota de tiempo arriba).
|
||||
pub vence_en: Option<u64>,
|
||||
/// Las firmas del quorum autorizador.
|
||||
pub firmantes: Vec<FirmaRevocacion>,
|
||||
}
|
||||
|
||||
/// El overlay de revocacion: la lista de revocaciones que el kernel consulta al
|
||||
/// arrancar. Objeto del grafo direccionado por contenido; el manifiesto guarda
|
||||
/// su hash en `overlay_revocacion`.
|
||||
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug, Default)]
|
||||
pub struct OverlayRevocacion {
|
||||
/// Version del format — debe ser [`VERSION_OVERLAY`].
|
||||
pub version: u32,
|
||||
/// Las revocaciones vigentes. El kernel aplica las que apunten a un slot del
|
||||
/// anillo y reunan el quorum; ignora el resto (no son su jurisdiccion).
|
||||
pub revocaciones: Vec<RevocacionFirmada>,
|
||||
}
|
||||
|
||||
impl OverlayRevocacion {
|
||||
/// Serializa el overlay a `postcard` — la carga util del objeto del grafo
|
||||
/// que lo aloja.
|
||||
pub fn serializar(&self) -> Result<Vec<u8>, &'static str> {
|
||||
postcard::to_allocvec(self).map_err(|_| "overlay_revocacion :: serializacion fallida")
|
||||
}
|
||||
|
||||
/// Reconstruye un overlay desde su forma binaria. Rechaza una version de
|
||||
/// format desconocida en lugar de malinterpretarla. Tolera bytes sobrantes
|
||||
/// tras la estructura — el relleno del registro.
|
||||
pub fn deserializar(bytes: &[u8]) -> Result<OverlayRevocacion, &'static str> {
|
||||
let (overlay, _) = postcard::take_from_bytes::<OverlayRevocacion>(bytes)
|
||||
.map_err(|_| "overlay_revocacion :: deserializacion fallida")?;
|
||||
if overlay.version != VERSION_OVERLAY {
|
||||
return Err("overlay_revocacion :: version de format desconocida");
|
||||
}
|
||||
Ok(overlay)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Fase 37 :: el sello criptografico del CUADERNO SOBERANO
|
||||
// -----------------------------------------------------------------------------
|
||||
// La integridad de un cuaderno —un nodo del grafo cuyo payload es
|
||||
// `Vec<CeldaWawa>` (Fase 43, modelo unificado)— se proteje en dos planos:
|
||||
//
|
||||
// * Localmente, el direccionamiento por contenido garantiza que un
|
||||
// bit alterado en cualquier celda cambia el hash del cuaderno
|
||||
// —y ese hash es la identidad del nodo en el almacen—.
|
||||
// * En la red capa-2 (Akasha), eso no basta: un peer hostil puede
|
||||
// reescribir el cuaderno entero y reanunciarlo con su propio hash.
|
||||
// Para que el sistema reconozca un cuaderno como SOBERANO del
|
||||
// operador local, el peer ha de adjuntar una firma Ed25519 del
|
||||
// cuaderno_raiz_hash producida con la clave privada que pertenece
|
||||
// a la `AGORA_PUBLIC_KEY_LOCAL` empotrada en el binario del kernel.
|
||||
//
|
||||
// Gemelo estructural de `ManifiestoFirmado`: la verificacion comparte
|
||||
// el camino Ring 0 zero-alloc de `ed25519-compact`.
|
||||
// =============================================================================
|
||||
|
||||
/// Sobre criptografico de un cuaderno: vincula su `hash` con un autor y
|
||||
/// una firma Ed25519. Sin este sobre, un cuaderno es solo un nodo mas
|
||||
/// del grafo — con el sobre, queda anclado como SOBERANO al usuario
|
||||
/// que firmo, y el kernel lo distingue de cualquier otro nodo cuaderno
|
||||
/// que viaje por la red.
|
||||
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
|
||||
pub struct CuadernoFirmado {
|
||||
/// Hash BLAKE3 del cuaderno propuesto. El payload del cuaderno es
|
||||
/// `Vec<CeldaWawa>` serializado con postcard; este hash es el
|
||||
/// resumen criptografico que va a engrapar la firma.
|
||||
pub cuaderno_raiz_hash: Hash,
|
||||
/// Llave publica Ed25519 del autor. El kernel la compara contra
|
||||
/// `AGORA_PUBLIC_KEY_LOCAL` antes de gastar un ciclo en criptografia
|
||||
/// — un autor ajeno cae con `CapacidadInsuficiente`.
|
||||
pub autor: AgoraId,
|
||||
/// Firma Ed25519 sobre los 32 bytes de `cuaderno_raiz_hash`.
|
||||
#[serde(with = "BigArray")]
|
||||
pub firma: Firma,
|
||||
}
|
||||
|
||||
impl CuadernoFirmado {
|
||||
/// Serializa el sobre a su forma binaria `postcard`. La forma cruda
|
||||
/// ocupa 32 + 32 + 64 = 128 bytes; postcard agrega un preludio
|
||||
/// minusculo (longitudes varint) que mantiene el sobre bajo 140 B.
|
||||
pub fn serializar(&self) -> Result<Vec<u8>, &'static str> {
|
||||
postcard::to_allocvec(self)
|
||||
.map_err(|_| "cuaderno_firmado :: serializacion fallida")
|
||||
}
|
||||
|
||||
/// Reconstruye un sobre desde su forma binaria. Tolera bytes
|
||||
/// sobrantes tras la estructura — el relleno del registro o el
|
||||
/// padding del payload del syscall.
|
||||
pub fn deserializar(bytes: &[u8]) -> Result<CuadernoFirmado, &'static str> {
|
||||
postcard::take_from_bytes::<CuadernoFirmado>(bytes)
|
||||
.map(|(cf, _)| cf)
|
||||
.map_err(|_| "cuaderno_firmado :: deserializacion fallida")
|
||||
}
|
||||
}
|
||||
|
||||
/// Una entrada del historial de un canal: una raiz de manifiesto, el instante
|
||||
/// en que el autor la propuso, y la firma Ed25519 con la que el autor la
|
||||
/// respalda. La firma se calcula sobre [`mensaje_a_firmar`].
|
||||
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
|
||||
pub struct RaizFirmada {
|
||||
/// Instante en que el autor propuso esta raiz, segundos desde UNIX epoch.
|
||||
/// Un receptor desconfia de raices con timestamp futuro mas alla de un
|
||||
/// margen razonable —proteccion barata contra anuncios envenenados—.
|
||||
pub timestamp: u64,
|
||||
/// El hash del [`Manifiesto`] que esta raiz inaugura. Re-anclar el
|
||||
/// superbloque a este hash es, literalmente, "actualizar a esta version".
|
||||
pub raiz_manifiesto: Hash,
|
||||
/// La firma Ed25519 del autor del canal sobre [`mensaje_a_firmar`].
|
||||
/// `serde` no derivara `Deserialize` para `[u8; 64]` sin ayuda —su soporte
|
||||
/// directo se detiene en 32 bytes—; `serde-big-array` cierra ese hueco.
|
||||
#[serde(with = "BigArray")]
|
||||
pub firma: Firma,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
use super::*;
|
||||
|
||||
// =============================================================================
|
||||
// Fase 33/43 :: el almacen semantico del cuaderno (modelo unificado)
|
||||
// -----------------------------------------------------------------------------
|
||||
// Un CUADERNO de Wawa es un nodo del grafo cuyo payload `postcard` es un
|
||||
// `Vec<CeldaWawa>`. Cada `CeldaWawa` empaqueta TODA la informacion de un
|
||||
// eslabon del calculo en una sola estructura inmutable:
|
||||
//
|
||||
// * `id_secuencial` :: indice lineal en el cuaderno.
|
||||
// * `fuente_hash` :: hash del texto Forth o token `@<hash>` literal.
|
||||
// * `binario_hash` :: hash del modulo WASM materializado (None si
|
||||
// la compilacion fallo).
|
||||
// * `ultimo_retorno` :: el i32 que la sub-jaula efimera devolvio
|
||||
// (None si nunca se ejecuto).
|
||||
// * `marca_error` :: bandera atomica: hubo TRAP, OUT_OF_FUEL,
|
||||
// PAYLOAD_INVALIDO, o cualquier otra falla.
|
||||
//
|
||||
// La fusion (Fase 43) elimina el enum heredado `TipoCeldaWawa` con sus
|
||||
// tres variantes flat — el modelo estructurado es mas honesto con la
|
||||
// semantica del cuaderno y converge bit-a-bit con la representacion
|
||||
// del motor Linux del ecosistema Pluma (`pluma-notebook-core`), que
|
||||
// re-exporta esta misma struct para hablar el mismo idioma en host y
|
||||
// en el silicio.
|
||||
//
|
||||
// Las aristas del nodo (los `hijos` que el almacen registra al insertar)
|
||||
// son: el CUADERNO PREVIO cuando existe (arista ancestral, Fase 47),
|
||||
// `fuente_hash` siempre, `binario_hash` cuando esta presente. El
|
||||
// direccionamiento por contenido hace EXPLICITAS las dependencias y
|
||||
// el cuaderno arrastra criptograficamente todo su tejido de causas y
|
||||
// efectos. Con la Fase 47, cada cuaderno apunta a su predecesor por
|
||||
// hash — el historial es una cadena recorrible por el Walker.
|
||||
//
|
||||
// Postcard-amigable: campos primitivos + `Option<T>` + arrays alineados.
|
||||
// La deserializacion del cuaderno no allocea fuera del `Vec` principal.
|
||||
// =============================================================================
|
||||
|
||||
/// El eslabon canonico de un cuaderno (Fase 43). Reemplaza al enum
|
||||
/// `TipoCeldaWawa` de la Fase 33: en lugar de tres variantes flat que
|
||||
/// solo el orden del `Vec` ataba a una "celda", aqui CADA `CeldaWawa`
|
||||
/// es una celda completa con todos sus eslabones bundled. Bit-compatible
|
||||
/// con `pluma_notebook_core::CeldaWawa` (re-export en el motor Linux).
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CeldaWawa {
|
||||
/// Indice lineal en el cuaderno — orden de presentacion. Empieza
|
||||
/// en 0 y crece con cada celda exitosamente registrada.
|
||||
pub id_secuencial: u32,
|
||||
/// Hash del texto fuente: ASCII Forth tecleado por el humano, o
|
||||
/// la cadena literal `@<64-hex>` para celdas macro-importadas
|
||||
/// (Fase 36, Cross-App Bridge). Siempre presente — una celda sin
|
||||
/// fuente es incoherente con el modelo.
|
||||
pub fuente_hash: Hash,
|
||||
/// Hash del modulo WASM materializado por `forth-emisor` (o
|
||||
/// importado del grafo via `@<hash>`). `None` cuando la compilacion
|
||||
/// fallo, la sintaxis Forth fue rechazada, o la vinculacion macro
|
||||
/// no se logro — el binario no llego a inscribirse.
|
||||
pub binario_hash: Option<Hash>,
|
||||
/// El i32 que la sub-jaula efimera (Fase 32) devolvio en su ultima
|
||||
/// ejecucion. `None` cuando la celda nunca corrio (sin binario, o
|
||||
/// el despacho dinamico ni siquiera arranco). Un valor negativo
|
||||
/// en `[-7, -1]` reservado en `CodigoError` codifica fallas
|
||||
/// controladas; valores fuera de ese rango son resultados legitimos.
|
||||
pub ultimo_retorno: Option<i32>,
|
||||
/// Bandera atomica de error: `true` si CUALQUIER eslabon de la
|
||||
/// cadena (compilacion, registro v2, ejecucion dinamica, anclaje
|
||||
/// de cuaderno) devolvio fallo. El renderer la usa para teñir la
|
||||
/// celda de amarillo palido sin enterrar el valor del retorno —
|
||||
/// `marca_error && ultimo_retorno == Some(-7)` significa
|
||||
/// "ejecutada, fallida con trap"; `marca_error && ultimo_retorno
|
||||
/// == None` significa "ni siquiera corrio".
|
||||
pub marca_error: bool,
|
||||
}
|
||||
|
||||
/// Serializa una secuencia de celdas a `postcard` — la forma que el
|
||||
/// kernel inscribe como payload del nodo cuaderno. Centralizada aqui
|
||||
/// para que el kernel no tenga que declarar `postcard` directamente
|
||||
/// (ya lo hereda transitivamente via `format`).
|
||||
pub fn serializar_celdas(celdas: &[CeldaWawa]) -> Result<Vec<u8>, &'static str> {
|
||||
postcard::to_allocvec(celdas).map_err(|_| "celdas :: serializacion fallida")
|
||||
}
|
||||
|
||||
/// Reconstruye la secuencia de celdas desde el payload de un nodo cuaderno.
|
||||
/// Tolera bytes sobrantes — el relleno del registro vive despues del payload.
|
||||
pub fn deserializar_celdas(bytes: &[u8]) -> Result<Vec<CeldaWawa>, &'static str> {
|
||||
postcard::take_from_bytes::<Vec<CeldaWawa>>(bytes)
|
||||
.map(|(celdas, _)| celdas)
|
||||
.map_err(|_| "celdas :: deserializacion fallida")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// (De)serializacion — la forma binaria que viaja al disco
|
||||
// =============================================================================
|
||||
|
||||
impl Objeto {
|
||||
/// Serializa el objeto a su forma binaria `postcard`.
|
||||
pub fn serializar(&self) -> Result<Vec<u8>, &'static str> {
|
||||
postcard::to_allocvec(self).map_err(|_| "objeto :: serializacion fallida")
|
||||
}
|
||||
|
||||
/// Reconstruye un objeto desde su forma binaria. Tolera bytes sobrantes
|
||||
/// tras el objeto —el relleno del registro—: solo consume su prefijo.
|
||||
pub fn deserializar(bytes: &[u8]) -> Result<Objeto, &'static str> {
|
||||
postcard::take_from_bytes::<Objeto>(bytes)
|
||||
.map(|(objeto, _)| objeto)
|
||||
.map_err(|_| "objeto :: deserializacion fallida")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Fase 66 :: Árbol/Blob — el monorepo como grafo
|
||||
// -----------------------------------------------------------------------------
|
||||
// El grafo direccionado por contenido ES el modelo de objetos de git. Esta
|
||||
// capa lo hace explícito para que un árbol de directorios viva en el grafo:
|
||||
//
|
||||
// * BLOB :: el contenido de un archivo. Es un `Objeto { datos: bytes,
|
||||
// hijos: [] }` — sin estructura, solo bytes direccionados por
|
||||
// su hash. Archivos idénticos comparten un solo blob (dedup
|
||||
// por contenido, gratis).
|
||||
// * ÁRBOL :: el contenido de un directorio. Un `Objeto` cuyo `datos` es
|
||||
// un `Arbol` postcard (la lista de entradas: nombre + modo +
|
||||
// hash) y cuyos `hijos` son los hashes de esas entradas — así
|
||||
// el MARK del GC del kernel alcanza todo el subárbol siguiendo
|
||||
// `hijos`, SIN tener que entender el format `Arbol`.
|
||||
//
|
||||
// Las entradas de un árbol van ORDENADAS por nombre: mismo contenido de
|
||||
// directorio => mismo árbol serializado => mismo hash. Determinismo total, la
|
||||
// base de la dedup y de la verificación. Un repositorio entero colapsa a UN
|
||||
// hash raíz; dos commits que solo tocan un archivo comparten todo el resto del
|
||||
// árbol (estructura compartida, como git).
|
||||
// =============================================================================
|
||||
|
||||
/// Version del format de un `Arbol`.
|
||||
pub const VERSION_ARBOL: u32 = 1;
|
||||
|
||||
/// Qué clase de objeto referencia una entrada de árbol. Espeja los modos de
|
||||
/// git (archivo / archivo+x / symlink / directorio). Variantes AÑADIDAS AL
|
||||
/// FINAL: los tags `postcard` se asignan por orden y mover una romperia árboles
|
||||
/// ya serializados.
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum ModoEntrada {
|
||||
/// El hash apunta a un archivo regular (blob plano o índice de trozos).
|
||||
Archivo,
|
||||
/// El hash apunta a otro ÁRBOL (subdirectorio).
|
||||
Directorio,
|
||||
/// Como `Archivo` pero con bit de ejecución (un script, un binario).
|
||||
Ejecutable,
|
||||
/// El hash apunta a un blob cuyo contenido es el DESTINO del enlace
|
||||
/// simbólico (la ruta a la que apunta), en UTF-8.
|
||||
Symlink,
|
||||
}
|
||||
|
||||
impl ModoEntrada {
|
||||
/// `true` si el modo referencia CONTENIDO de archivo (blob/índice): un
|
||||
/// archivo regular o un ejecutable. `Symlink` y `Directorio` no.
|
||||
pub fn es_archivo(&self) -> bool {
|
||||
matches!(self, ModoEntrada::Archivo | ModoEntrada::Ejecutable)
|
||||
}
|
||||
}
|
||||
|
||||
/// Una entrada de un árbol: un nombre dentro del directorio + el modo + el hash
|
||||
/// del objeto que la realiza (un blob si `Archivo`, un árbol si `Directorio`).
|
||||
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
|
||||
pub struct EntradaArbol {
|
||||
/// Nombre del archivo/subdirectorio (sin separadores de ruta).
|
||||
pub nombre: String,
|
||||
/// Si el hash apunta a un blob o a un subárbol.
|
||||
pub modo: ModoEntrada,
|
||||
/// Hash del objeto (blob o árbol) que esta entrada referencia.
|
||||
pub hash: Hash,
|
||||
}
|
||||
|
||||
/// Un árbol: el contenido de un directorio, como lista ordenada de entradas.
|
||||
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
|
||||
pub struct Arbol {
|
||||
/// Version del format — debe ser [`VERSION_ARBOL`].
|
||||
pub version: u32,
|
||||
/// Entradas ORDENADAS por nombre (invariante que `objeto_arbol` impone).
|
||||
pub entradas: Vec<EntradaArbol>,
|
||||
}
|
||||
|
||||
impl Arbol {
|
||||
/// Serializa el árbol a su forma `postcard` —la carga útil del objeto que
|
||||
/// lo aloja—.
|
||||
pub fn serializar(&self) -> Result<Vec<u8>, &'static str> {
|
||||
postcard::to_allocvec(self).map_err(|_| "arbol :: serializacion fallida")
|
||||
}
|
||||
|
||||
/// Reconstruye un árbol desde la carga útil de su objeto. Rechaza una
|
||||
/// version desconocida en lugar de malinterpretarla.
|
||||
pub fn deserializar(bytes: &[u8]) -> Result<Arbol, &'static str> {
|
||||
let (arbol, _) =
|
||||
postcard::take_from_bytes::<Arbol>(bytes).map_err(|_| "arbol :: deserializacion fallida")?;
|
||||
if arbol.version != VERSION_ARBOL {
|
||||
return Err("arbol :: version de format desconocida");
|
||||
}
|
||||
Ok(arbol)
|
||||
}
|
||||
}
|
||||
|
||||
/// Construye el objeto BLOB de un archivo: bytes crudos, sin hijos. El hash de
|
||||
/// este objeto (sobre su forma serializada) es la identidad del archivo en el
|
||||
/// grafo. Dos archivos con idéntico contenido producen el MISMO blob.
|
||||
pub fn objeto_blob(datos: Vec<u8>) -> Objeto {
|
||||
Objeto {
|
||||
datos,
|
||||
hijos: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Construye el objeto ÍNDICE de un archivo GRANDE partido en trozos: `datos`
|
||||
/// VACÍO, `hijos` = los hashes de los blobs-trozo EN ORDEN. La convención de
|
||||
/// lectura: una entrada de archivo (`Archivo`/`Ejecutable`) cuyo objeto tiene
|
||||
/// `hijos` no vacío es un índice, y el contenido del archivo es la
|
||||
/// concatenación de los `datos` de sus trozos; si `hijos` está vacío, el
|
||||
/// objeto ES el contenido (blob plano). Así un archivo de cualquier tamaño se
|
||||
/// referencia igual desde el árbol — el lector decide plano vs índice por la
|
||||
/// forma del objeto, sin un modo aparte. Un archivo vacío es un blob plano
|
||||
/// (`datos` vacío, `hijos` vacío), nunca un índice.
|
||||
pub fn objeto_blob_indice(hijos: Vec<Hash>) -> Objeto {
|
||||
Objeto {
|
||||
datos: Vec::new(),
|
||||
hijos,
|
||||
}
|
||||
}
|
||||
|
||||
/// Construye el objeto ÁRBOL de un directorio a partir de sus entradas. ORDENA
|
||||
/// las entradas por nombre (determinismo: mismo directorio → mismo hash) y fija
|
||||
/// `hijos` con los hashes de las entradas, en el MISMO orden, para que el GC
|
||||
/// alcance el subárbol siguiendo `hijos` sin parsear el `Arbol`.
|
||||
pub fn objeto_arbol(mut entradas: Vec<EntradaArbol>) -> Result<Objeto, &'static str> {
|
||||
entradas.sort_by(|a, b| a.nombre.cmp(&b.nombre));
|
||||
let hijos: Vec<Hash> = entradas.iter().map(|e| e.hash).collect();
|
||||
let arbol = Arbol {
|
||||
version: VERSION_ARBOL,
|
||||
entradas,
|
||||
};
|
||||
let datos = arbol.serializar()?;
|
||||
Ok(Objeto { datos, hijos })
|
||||
}
|
||||
|
||||
impl SuperBloque {
|
||||
/// Serializa el superbloque a su forma binaria `postcard`.
|
||||
pub fn serializar(&self) -> Result<Vec<u8>, &'static str> {
|
||||
postcard::to_allocvec(self).map_err(|_| "superbloque :: serializacion fallida")
|
||||
}
|
||||
|
||||
/// Reconstruye el superbloque desde el sector 0. Tolera el relleno a cero
|
||||
/// que completa el sector: solo consume el prefijo serializado.
|
||||
pub fn deserializar(bytes: &[u8]) -> Result<SuperBloque, &'static str> {
|
||||
postcard::take_from_bytes::<SuperBloque>(bytes)
|
||||
.map(|(sb, _)| sb)
|
||||
.map_err(|_| "superbloque :: deserializacion fallida")
|
||||
}
|
||||
}
|
||||
|
||||
impl Manifiesto {
|
||||
/// Serializa el manifiesto a su forma binaria `postcard` — la carga util
|
||||
/// del objeto del grafo que lo aloja.
|
||||
pub fn serializar(&self) -> Result<Vec<u8>, &'static str> {
|
||||
postcard::to_allocvec(self).map_err(|_| "manifiesto :: serializacion fallida")
|
||||
}
|
||||
|
||||
/// Reconstruye un manifiesto desde la carga util de su objeto. Rechaza un
|
||||
/// format de version desconocida en lugar de malinterpretarlo.
|
||||
pub fn deserializar(bytes: &[u8]) -> Result<Manifiesto, &'static str> {
|
||||
let (manifiesto, _) = postcard::take_from_bytes::<Manifiesto>(bytes)
|
||||
.map_err(|_| "manifiesto :: deserializacion fallida")?;
|
||||
if manifiesto.version != VERSION_MANIFIESTO {
|
||||
return Err("manifiesto :: version de format desconocida");
|
||||
}
|
||||
Ok(manifiesto)
|
||||
}
|
||||
}
|
||||
|
||||
impl Canal {
|
||||
/// Serializa el canal a su forma binaria `postcard` — la carga util del
|
||||
/// objeto del grafo que lo aloja. Rechaza por adelantado un nombre que
|
||||
/// supere [`NOMBRE_CANAL_LIMITE`]: mejor un error de serializacion que un
|
||||
/// canal grafico que no quepa en disco.
|
||||
pub fn serializar(&self) -> Result<Vec<u8>, &'static str> {
|
||||
if self.nombre.len() > NOMBRE_CANAL_LIMITE {
|
||||
return Err("canal :: nombre demasiado largo");
|
||||
}
|
||||
postcard::to_allocvec(self).map_err(|_| "canal :: serializacion fallida")
|
||||
}
|
||||
|
||||
/// Reconstruye un canal desde la carga util de su objeto. Rechaza version
|
||||
/// desconocida y nombres que excedan [`NOMBRE_CANAL_LIMITE`] —un canal con
|
||||
/// nombre extravagante se detecta al recibirlo, no al servirlo—.
|
||||
pub fn deserializar(bytes: &[u8]) -> Result<Canal, &'static str> {
|
||||
let (canal, _) = postcard::take_from_bytes::<Canal>(bytes)
|
||||
.map_err(|_| "canal :: deserializacion fallida")?;
|
||||
if canal.version != VERSION_CANAL {
|
||||
return Err("canal :: version de format desconocida");
|
||||
}
|
||||
if canal.nombre.len() > NOMBRE_CANAL_LIMITE {
|
||||
return Err("canal :: nombre excede el techo");
|
||||
}
|
||||
Ok(canal)
|
||||
}
|
||||
|
||||
/// La recomendacion vigente del canal: la ultima `RaizFirmada` por
|
||||
/// `timestamp`, o `None` si el canal aun no propuso ninguna. Quien quiera
|
||||
/// "actualizar" sigue este hash; quien quiera rollback elige otra entrada
|
||||
/// del historial.
|
||||
pub fn vigente(&self) -> Option<&RaizFirmada> {
|
||||
self.raices.last()
|
||||
}
|
||||
}
|
||||
|
||||
/// Compone el mensaje canonico que un autor firma para respaldar una raiz en
|
||||
/// un canal: la concatenacion `nombre || timestamp_le || raiz_manifiesto`.
|
||||
/// Es la unica verdad del payload firmable —quien firma y quien verifica han
|
||||
/// de componerlo por aqui, jamas a mano—. La canonizacion incluye el nombre
|
||||
/// del canal para que una firma valida en `dev` no se replique en `estable`.
|
||||
pub fn mensaje_a_firmar(nombre_canal: &str, timestamp: u64, raiz_manifiesto: &Hash) -> Vec<u8> {
|
||||
let mut mensaje = Vec::with_capacity(nombre_canal.len() + 8 + raiz_manifiesto.len());
|
||||
mensaje.extend_from_slice(nombre_canal.as_bytes());
|
||||
mensaje.extend_from_slice(×tamp.to_le_bytes());
|
||||
mensaje.extend_from_slice(raiz_manifiesto);
|
||||
mensaje
|
||||
}
|
||||
|
||||
/// Compone el mensaje canonico que un autor firma para CONCEDER capacidad a un
|
||||
/// bytecode: `bytecode(32) || permisos_le(4)`. Es la unica verdad del payload
|
||||
/// firmable de una [`ConcesionCapacidad`] —firmante y verificador lo componen
|
||||
/// por aqui, jamas a mano—. Liga la firma al hash EXACTO del binario y al
|
||||
/// bitfield EXACTO: una concesion para el bytecode X no vale para Y, y subir un
|
||||
/// bit de permiso invalida la firma. Devuelve un arreglo de pila de 36 bytes:
|
||||
/// zero-alloc, apto para el camino Ring 0 del kernel.
|
||||
pub fn mensaje_capacidad(bytecode: &Hash, permisos: Permisos) -> [u8; 36] {
|
||||
let mut m = [0u8; 36];
|
||||
m[..32].copy_from_slice(bytecode);
|
||||
m[32..].copy_from_slice(&permisos.to_le_bytes());
|
||||
m
|
||||
}
|
||||
|
||||
/// Dominio de separacion del mensaje de ROTACION de clave. Un byte canonico de
|
||||
/// rotacion jamas colisiona con uno de revocacion ni con un claim del grafo.
|
||||
pub const DOM_ROTACION_CLAVE: &[u8] = b"agora-key-rotation\x01";
|
||||
|
||||
/// Dominio de separacion del mensaje de REVOCACION de clave.
|
||||
pub const DOM_REVOCACION_CLAVE: &[u8] = b"agora-revocation\x01";
|
||||
|
||||
/// Compone el mensaje canonico de una ROTACION de clave (handoff voluntario
|
||||
/// vieja->nueva): `DOM || old(32) || new(32) || issued_at_le(8)`. Tamanos fijos,
|
||||
/// sin prefijos de largo; el dominio lo separa de otros records. Es la unica
|
||||
/// verdad del payload firmable de una rotacion — `agora-core::KeyRotation` lo
|
||||
/// compone por aqui y el kernel lo espeja sobre estos mismos bytes (ver
|
||||
/// `agora/SDD-rotacion-revocacion.md` §2.1).
|
||||
pub fn mensaje_rotacion_clave(old_key: &[u8; 32], new_key: &[u8; 32], issued_at: u64) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(DOM_ROTACION_CLAVE.len() + 72);
|
||||
out.extend_from_slice(DOM_ROTACION_CLAVE);
|
||||
out.extend_from_slice(old_key);
|
||||
out.extend_from_slice(new_key);
|
||||
out.extend_from_slice(&issued_at.to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// Compone el mensaje canonico de una REVOCACION de clave:
|
||||
/// `DOM || target(32) || [motivo] || issued_at_le(8) || tag || [expires_le(8)]`,
|
||||
/// donde `tag` es `0` si `expires_at` es `None` y `1` si es `Some` (para que
|
||||
/// `None` y `Some(0)` no colisionen). El `motivo` es el discriminante estable de
|
||||
/// `agora-core::RevReason` (0=Compromised, 1=Retired, 2=Superseded) — entra en la
|
||||
/// firma para que no se pueda "ascender" un retiro a compromiso sin re-firmar.
|
||||
/// Unica verdad del payload firmable de una revocacion: `agora-core::Revocation`
|
||||
/// lo compone por aqui y el kernel lo espeja en `claves::verificar_revocacion`
|
||||
/// (ver `agora/SDD-rotacion-revocacion.md` §2.2 y §4).
|
||||
pub fn mensaje_revocacion_clave(
|
||||
target_key: &[u8; 32],
|
||||
motivo: u8,
|
||||
issued_at: u64,
|
||||
expires_at: Option<u64>,
|
||||
) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(DOM_REVOCACION_CLAVE.len() + 50);
|
||||
out.extend_from_slice(DOM_REVOCACION_CLAVE);
|
||||
out.extend_from_slice(target_key);
|
||||
out.push(motivo);
|
||||
out.extend_from_slice(&issued_at.to_le_bytes());
|
||||
match expires_at {
|
||||
None => out.push(0),
|
||||
Some(t) => {
|
||||
out.push(1);
|
||||
out.extend_from_slice(&t.to_le_bytes());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Permisos EFECTIVOS de una app: la INTERSECCION de lo que su `EntradaApp` del
|
||||
/// manifiesto DECLARA y lo que una [`ConcesionCapacidad`] valida CONCEDE para su
|
||||
/// bytecode. El manifiesto no puede escalar un binario mas alla de su concesion
|
||||
/// firmada, y una concesion generosa no enciende permisos que el manifiesto no
|
||||
/// pidio. Sin concesion valida, el llamante pasa `0` como `concedidos` y la app
|
||||
/// corre sin capacidades gateadas (la matriz pasiva siempre esta). Es la regla
|
||||
/// que el kernel aplica en el punto de carga —ver `SDD-capacidades.md`—.
|
||||
pub const fn permisos_efectivos(declarados: Permisos, concedidos: Permisos) -> Permisos {
|
||||
declarados & concedidos
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// =============================================================================
|
||||
// renaser :: format — el format del grafo de objetos en disco
|
||||
// -----------------------------------------------------------------------------
|
||||
// Hasta la Fase 7a, el format del grafo de objetos —el superbloque, los
|
||||
// registros del log, el manifiesto— vivia disperso entre `kernel/almacen.rs`
|
||||
// y `kernel/manifiesto.rs`. Lo conocia solo el kernel.
|
||||
//
|
||||
// La Fase 7b se lo entrega tambien a `boot`: el constructor de imagen de
|
||||
// ANFITRION debe sembrar el disco con el grafo ya poblado —los objetos de
|
||||
// bytecode y el Manifiesto de Genesis— para que el kernel jamas vuelva a
|
||||
// empotrar una sola app. Para ello, kernel y boot han de hablar EXACTAMENTE
|
||||
// el mismo format: la misma serializacion, el mismo hash, el mismo trazado
|
||||
// de registros en el log.
|
||||
//
|
||||
// Esta crate es esa unica verdad. Es un nucleo `#![no_std]` —el kernel
|
||||
// bare-metal la enlaza— y, por ser no_std, el anfitrion `boot` la compila sin
|
||||
// friccion. Define los tipos del grafo, su (de)serializacion `postcard`, la
|
||||
// funcion hash BLAKE3 que da identidad a cada objeto y el trazado de un
|
||||
// registro en el log. Ni kernel ni boot vuelven a definir nada de esto.
|
||||
// =============================================================================
|
||||
|
||||
#![no_std]
|
||||
|
||||
extern crate alloc;
|
||||
|
||||
use alloc::string::String;
|
||||
use alloc::vec;
|
||||
use alloc::vec::Vec;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_big_array::BigArray;
|
||||
|
||||
// --- Split temático del format (todo pub; API plana preservada con `pub use
|
||||
// <mod>::*`). Cada módulo abre con `use super::*` para ver los otros tipos +
|
||||
// las imports de alloc/serde del root. Sigue siendo `#![no_std]`. ---
|
||||
mod cable;
|
||||
mod constantes;
|
||||
mod firma;
|
||||
mod grafo;
|
||||
mod tipos;
|
||||
#[cfg(test)]
|
||||
mod pruebas;
|
||||
|
||||
pub use cable::*;
|
||||
pub use constantes::*;
|
||||
pub use firma::*;
|
||||
pub use grafo::*;
|
||||
pub use tipos::*;
|
||||
@@ -0,0 +1,904 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn blob_no_tiene_hijos() {
|
||||
let b = objeto_blob(vec![0xAA, 0xBB, 0xCC]);
|
||||
assert_eq!(b.datos, vec![0xAA, 0xBB, 0xCC]);
|
||||
assert!(b.hijos.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arbol_ordena_entradas_por_nombre() {
|
||||
// Entradas en orden caótico — el objeto-árbol debe ordenarlas.
|
||||
let entradas = vec![
|
||||
EntradaArbol { nombre: "zeta.rs".into(), modo: ModoEntrada::Archivo, hash: [1; 32] },
|
||||
EntradaArbol { nombre: "alfa.rs".into(), modo: ModoEntrada::Archivo, hash: [2; 32] },
|
||||
EntradaArbol { nombre: "sub".into(), modo: ModoEntrada::Directorio, hash: [3; 32] },
|
||||
];
|
||||
let obj = objeto_arbol(entradas).unwrap();
|
||||
let arbol = Arbol::deserializar(&obj.datos).unwrap();
|
||||
let nombres: Vec<&str> = arbol.entradas.iter().map(|e| e.nombre.as_str()).collect();
|
||||
assert_eq!(nombres, ["alfa.rs", "sub", "zeta.rs"]);
|
||||
// `hijos` viaja en el MISMO orden que las entradas ordenadas.
|
||||
assert_eq!(obj.hijos, vec![[2u8; 32], [3u8; 32], [1u8; 32]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arbol_es_determinista_independiente_del_orden_de_entrada() {
|
||||
// El mismo directorio dado en dos órdenes distintos => MISMO hash.
|
||||
let a = vec![
|
||||
EntradaArbol { nombre: "b".into(), modo: ModoEntrada::Archivo, hash: [5; 32] },
|
||||
EntradaArbol { nombre: "a".into(), modo: ModoEntrada::Archivo, hash: [6; 32] },
|
||||
];
|
||||
let b = vec![
|
||||
EntradaArbol { nombre: "a".into(), modo: ModoEntrada::Archivo, hash: [6; 32] },
|
||||
EntradaArbol { nombre: "b".into(), modo: ModoEntrada::Archivo, hash: [5; 32] },
|
||||
];
|
||||
let ha = hash(&objeto_arbol(a).unwrap().serializar().unwrap());
|
||||
let hb = hash(&objeto_arbol(b).unwrap().serializar().unwrap());
|
||||
assert_eq!(ha, hb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arbol_rechaza_version_desconocida() {
|
||||
let mut arbol = Arbol { version: VERSION_ARBOL, entradas: vec![] };
|
||||
assert!(Arbol::deserializar(&arbol.serializar().unwrap()).is_ok());
|
||||
arbol.version = 99;
|
||||
assert!(Arbol::deserializar(&arbol.serializar().unwrap()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indice_de_blob_grande_tiene_datos_vacio_e_hijos() {
|
||||
let idx = objeto_blob_indice(vec![[1; 32], [2; 32], [3; 32]]);
|
||||
assert!(idx.datos.is_empty(), "el índice no porta datos, solo hijos");
|
||||
assert_eq!(idx.hijos, vec![[1u8; 32], [2u8; 32], [3u8; 32]]);
|
||||
// Distinguible de un archivo vacío (blob plano): hijos no vacío.
|
||||
let vacio = objeto_blob(vec![]);
|
||||
assert!(vacio.hijos.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modo_es_archivo_distingue_contenido_de_estructura() {
|
||||
assert!(ModoEntrada::Archivo.es_archivo());
|
||||
assert!(ModoEntrada::Ejecutable.es_archivo());
|
||||
assert!(!ModoEntrada::Symlink.es_archivo());
|
||||
assert!(!ModoEntrada::Directorio.es_archivo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modos_nuevos_sobreviven_round_trip_en_arbol() {
|
||||
let entradas = vec![
|
||||
EntradaArbol { nombre: "run.sh".into(), modo: ModoEntrada::Ejecutable, hash: [1; 32] },
|
||||
EntradaArbol { nombre: "link".into(), modo: ModoEntrada::Symlink, hash: [2; 32] },
|
||||
];
|
||||
let obj = objeto_arbol(entradas).unwrap();
|
||||
let arbol = Arbol::deserializar(&obj.datos).unwrap();
|
||||
assert_eq!(arbol.entradas[0].nombre, "link");
|
||||
assert_eq!(arbol.entradas[0].modo, ModoEntrada::Symlink);
|
||||
assert_eq!(arbol.entradas[1].modo, ModoEntrada::Ejecutable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objeto_ida_y_vuelta() {
|
||||
let objeto = Objeto {
|
||||
datos: vec![1, 2, 3, 4, 5],
|
||||
hijos: vec![[7u8; 32], [9u8; 32]],
|
||||
};
|
||||
let bytes = objeto.serializar().unwrap();
|
||||
assert_eq!(Objeto::deserializar(&bytes).unwrap(), objeto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registro_alineado_a_sector() {
|
||||
let payload = vec![0xABu8; 600];
|
||||
let registro = componer_registro(&payload);
|
||||
// 4 + 600 = 604 bytes => dos sectores de 512.
|
||||
assert_eq!(registro.len(), 2 * TAM_SECTOR);
|
||||
assert_eq!(registro.len() % TAM_SECTOR, 0);
|
||||
assert_eq!(longitud_registro(®istro), Some(600));
|
||||
assert_eq!(®istro[4..604], &payload[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cuaderno_ida_y_vuelta_con_celdas_mixtas() {
|
||||
// FASE 43 :: el modelo unificado CeldaWawa empaqueta los cinco
|
||||
// campos en una sola struct. Roundtrip cubre:
|
||||
// - celda exitosa con binario y retorno legitimo
|
||||
// - celda fallida sin binario, sin retorno, con `marca_error`
|
||||
// - celda fallida con binario pero retorno negativo y error
|
||||
let celdas: Vec<CeldaWawa> = vec![
|
||||
CeldaWawa {
|
||||
id_secuencial: 0,
|
||||
fuente_hash: [0xA1; 32],
|
||||
binario_hash: Some([0xB2; 32]),
|
||||
ultimo_retorno: Some(42),
|
||||
marca_error: false,
|
||||
},
|
||||
CeldaWawa {
|
||||
id_secuencial: 1,
|
||||
fuente_hash: [0xC3; 32],
|
||||
binario_hash: None,
|
||||
ultimo_retorno: None,
|
||||
marca_error: true,
|
||||
},
|
||||
CeldaWawa {
|
||||
id_secuencial: 2,
|
||||
fuente_hash: [0xD4; 32],
|
||||
binario_hash: Some([0xE5; 32]),
|
||||
ultimo_retorno: Some(-7),
|
||||
marca_error: true,
|
||||
},
|
||||
];
|
||||
let bytes = serializar_celdas(&celdas).unwrap();
|
||||
let leido = deserializar_celdas(&bytes).unwrap();
|
||||
assert_eq!(leido, celdas);
|
||||
|
||||
// Single-cell payload (el caso que produce la PRIMERA anexion
|
||||
// de `sys_cuaderno_anexar_celda` sobre un cuaderno virgen).
|
||||
let una: Vec<CeldaWawa> = vec![CeldaWawa {
|
||||
id_secuencial: 99,
|
||||
fuente_hash: [0xF0; 32],
|
||||
binario_hash: None,
|
||||
ultimo_retorno: Some(0),
|
||||
marca_error: false,
|
||||
}];
|
||||
let bytes = serializar_celdas(&una).unwrap();
|
||||
let leido = deserializar_celdas(&bytes).unwrap();
|
||||
assert_eq!(leido, una);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cuaderno_acumulativo_anexa_celdas_en_orden() {
|
||||
// FASE 47 :: la nueva syscall `sys_cuaderno_anexar_celda` opera
|
||||
// en el kernel como: recuperar -> deserializar Vec<CeldaWawa> ->
|
||||
// push(nueva) -> reserializar. Este test reproduce esa cadena
|
||||
// en miniatura, asegurando que el roundtrip respeta el orden
|
||||
// cronologico real con id_secuencial creciente.
|
||||
let mut acumulado: Vec<CeldaWawa> = Vec::new();
|
||||
for i in 0..5u32 {
|
||||
// Re-deserializar lo que el kernel "tendria en disco" antes
|
||||
// del push — refleja exactamente la operacion del host.
|
||||
let acumulado_actual = if acumulado.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let bytes = serializar_celdas(&acumulado).unwrap();
|
||||
deserializar_celdas(&bytes).unwrap()
|
||||
};
|
||||
let mut siguiente = acumulado_actual;
|
||||
siguiente.push(CeldaWawa {
|
||||
id_secuencial: i,
|
||||
fuente_hash: [i as u8; 32],
|
||||
binario_hash: if i % 2 == 0 {
|
||||
Some([(i + 0x10) as u8; 32])
|
||||
} else {
|
||||
None
|
||||
},
|
||||
ultimo_retorno: Some(i as i32),
|
||||
marca_error: i % 3 == 0,
|
||||
});
|
||||
acumulado = siguiente;
|
||||
}
|
||||
// Tras 5 anexiones, el cuaderno tiene 5 celdas en orden 0..5.
|
||||
assert_eq!(acumulado.len(), 5);
|
||||
for (i, c) in acumulado.iter().enumerate() {
|
||||
assert_eq!(c.id_secuencial, i as u32);
|
||||
}
|
||||
// Roundtrip final del vector acumulado preserva la cadena.
|
||||
let bytes = serializar_celdas(&acumulado).unwrap();
|
||||
let leido = deserializar_celdas(&bytes).unwrap();
|
||||
assert_eq!(leido, acumulado);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cabecera_a_cero_es_fin_del_log() {
|
||||
assert_eq!(longitud_registro(&[0, 0, 0, 0]), None);
|
||||
assert_eq!(longitud_registro(&[0xFF, 0xFF, 0xFF, 0xFF]), None);
|
||||
assert_eq!(longitud_registro(&[3, 0, 0, 0]), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifiesto_rechaza_version_ajena() {
|
||||
let mut manifiesto = Manifiesto {
|
||||
version: 99,
|
||||
apps: Vec::new(),
|
||||
configuracion: None,
|
||||
overlay_revocacion: None,
|
||||
marco: None,
|
||||
};
|
||||
let bytes = postcard::to_allocvec(&manifiesto).unwrap();
|
||||
assert!(Manifiesto::deserializar(&bytes).is_err());
|
||||
manifiesto.version = VERSION_MANIFIESTO;
|
||||
assert!(Manifiesto::deserializar(&manifiesto.serializar().unwrap()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifiesto_transporta_enlace_de_configuracion() {
|
||||
// Un manifiesto puede nacer sin configuracion (defecto) o cargar el
|
||||
// hash de un nodo de configuracion en el grafo. Lo que el `serializar`
|
||||
// escribe es exactamente lo que el `deserializar` recupera.
|
||||
let con_enlace = Manifiesto {
|
||||
version: VERSION_MANIFIESTO,
|
||||
apps: Vec::new(),
|
||||
configuracion: Some([0xC5; 32]),
|
||||
overlay_revocacion: None,
|
||||
marco: None,
|
||||
};
|
||||
let bytes = con_enlace.serializar().unwrap();
|
||||
let leido = Manifiesto::deserializar(&bytes).unwrap();
|
||||
assert_eq!(leido.configuracion, Some([0xC5; 32]));
|
||||
|
||||
let sin_enlace = Manifiesto {
|
||||
version: VERSION_MANIFIESTO,
|
||||
apps: Vec::new(),
|
||||
configuracion: None,
|
||||
overlay_revocacion: None,
|
||||
marco: None,
|
||||
};
|
||||
let bytes = sin_enlace.serializar().unwrap();
|
||||
assert!(Manifiesto::deserializar(&bytes)
|
||||
.unwrap()
|
||||
.configuracion
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuracion_ida_y_vuelta_y_rechaza_version() {
|
||||
let cfg = Configuracion {
|
||||
version: VERSION_CONFIGURACION,
|
||||
idioma: idioma_iso639(*b"qu"),
|
||||
paleta: [
|
||||
0x11, 0x22, 0x33, 0xFF, 0x44, 0x55, 0x66, 0xFF, 0x77, 0x88, 0x99, 0xFF, 0xAA, 0xBB,
|
||||
0xCC, 0xFF, 0xDD, 0xEE, 0xFF, 0xFF,
|
||||
],
|
||||
};
|
||||
let bytes = cfg.serializar().unwrap();
|
||||
assert_eq!(Configuracion::deserializar(&bytes).unwrap(), cfg);
|
||||
|
||||
// Hashes distintos => identidades distintas. Cambiar la paleta o el
|
||||
// idioma engendra un nodo nuevo del grafo; ningun cambio se cuela
|
||||
// bajo el mismo hash.
|
||||
let mut otro = cfg;
|
||||
otro.idioma = idioma_iso639(*b"en");
|
||||
assert_ne!(hash(&otro.serializar().unwrap()), hash(&bytes));
|
||||
|
||||
// Version desconocida: se rechaza al deserializar.
|
||||
let mut ajeno = cfg;
|
||||
ajeno.version = 99;
|
||||
let bytes_ajenos = postcard::to_allocvec(&ajeno).unwrap();
|
||||
assert!(Configuracion::deserializar(&bytes_ajenos).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuracion_por_defecto_es_estable() {
|
||||
// El `por_defecto` debe ser determinista y reconstruirse desde su
|
||||
// forma binaria sin perder ningun campo. El kernel lo inyecta tal
|
||||
// cual cuando el manifiesto no enlaza configuracion alguna.
|
||||
let defecto = Configuracion::por_defecto();
|
||||
assert_eq!(defecto.version, VERSION_CONFIGURACION);
|
||||
assert_eq!(defecto.idioma, IDIOMA_DEFECTO);
|
||||
assert_eq!(defecto.paleta, PALETA_DEFECTO);
|
||||
let bytes = defecto.serializar().unwrap();
|
||||
assert_eq!(Configuracion::deserializar(&bytes).unwrap(), defecto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entrada_app_transporta_permisos_y_distingue_hash() {
|
||||
// Una entrada con permisos distintos engendra un manifiesto con un
|
||||
// hash distinto: el bit es CONTENIDO direccionado, no metadato lateral.
|
||||
// Una app que se "regala" un permiso a si misma no puede pasar por
|
||||
// la misma app del manifiesto anterior — el grafo lo delata.
|
||||
let base = EntradaApp {
|
||||
nombre: String::from("test"),
|
||||
bytecode: [0x11; 32],
|
||||
region_x: 0,
|
||||
region_y: 0,
|
||||
region_ancho: 100,
|
||||
region_alto: 100,
|
||||
techo_memoria: 4 * 1024 * 1024,
|
||||
fuel_fotograma: 1_000_000,
|
||||
estado: None,
|
||||
permisos: 0,
|
||||
concesion: None,
|
||||
};
|
||||
let mut con_red = base.clone();
|
||||
con_red.permisos = PERMISO_RED;
|
||||
let manifiesto_a = Manifiesto {
|
||||
version: VERSION_MANIFIESTO,
|
||||
apps: vec![base.clone()],
|
||||
configuracion: None,
|
||||
overlay_revocacion: None,
|
||||
marco: None,
|
||||
};
|
||||
let manifiesto_b = Manifiesto {
|
||||
version: VERSION_MANIFIESTO,
|
||||
apps: vec![con_red],
|
||||
configuracion: None,
|
||||
overlay_revocacion: None,
|
||||
marco: None,
|
||||
};
|
||||
assert_ne!(
|
||||
hash(&manifiesto_a.serializar().unwrap()),
|
||||
hash(&manifiesto_b.serializar().unwrap()),
|
||||
"manifiestos con distintos permisos deben dar hashes distintos"
|
||||
);
|
||||
|
||||
// El roundtrip preserva la mascara entera.
|
||||
let con_todo = EntradaApp {
|
||||
permisos: PERMISO_RED
|
||||
| PERMISO_GRAFO_ESCRITURA
|
||||
| PERMISO_RAIZ
|
||||
| PERMISO_ALTAVOZ
|
||||
| PERMISO_CONFIG
|
||||
| PERMISO_COMPACTAR,
|
||||
..base.clone()
|
||||
};
|
||||
let m = Manifiesto {
|
||||
version: VERSION_MANIFIESTO,
|
||||
apps: vec![con_todo],
|
||||
configuracion: None,
|
||||
overlay_revocacion: None,
|
||||
marco: None,
|
||||
};
|
||||
let bytes = m.serializar().unwrap();
|
||||
let leido = Manifiesto::deserializar(&bytes).unwrap();
|
||||
assert_eq!(leido.apps[0].permisos, 0b111111);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifiesto_firmado_ida_y_vuelta() {
|
||||
// Roundtrip serializar->deserializar preserva los tres campos del
|
||||
// sobre criptografico: hash del manifiesto, llave publica del autor
|
||||
// y firma. Es el contrato basico de la Fase 25 con el wire/log.
|
||||
let mf = ManifiestoFirmado {
|
||||
manifiesto_hash: [0xC5; 32],
|
||||
autor: [0xA1; 32],
|
||||
firma: [0x77; 64],
|
||||
};
|
||||
let bytes = mf.serializar().unwrap();
|
||||
let leido = ManifiestoFirmado::deserializar(&bytes).unwrap();
|
||||
assert_eq!(leido, mf);
|
||||
// Tamaño acotado: 32 + 32 + 64 = 128 bytes crudos + el preludio
|
||||
// postcard. Debe caber holgado en un sector y en un frame Ethernet.
|
||||
assert!(bytes.len() <= 160, "MF demasiado grande: {} bytes", bytes.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cuaderno_firmado_ida_y_vuelta() {
|
||||
// Roundtrip estructural del sobre criptografico del cuaderno
|
||||
// (Fase 37). Gemelo a `manifiesto_firmado_ida_y_vuelta` — el
|
||||
// mismo contrato de los tres campos contra el wire/log.
|
||||
let cf = CuadernoFirmado {
|
||||
cuaderno_raiz_hash: [0xCE; 32],
|
||||
autor: [0xA1; 32],
|
||||
firma: [0x66; 64],
|
||||
};
|
||||
let bytes = cf.serializar().unwrap();
|
||||
let leido = CuadernoFirmado::deserializar(&bytes).unwrap();
|
||||
assert_eq!(leido, cf);
|
||||
assert!(
|
||||
bytes.len() <= 160,
|
||||
"CuadernoFirmado demasiado grande: {} bytes",
|
||||
bytes.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codigo_error_tiene_valores_estables() {
|
||||
// Anadir una variante NUEVA al enum jamas debe renumerar las
|
||||
// existentes: el binario WASM viejo compila contra el numero
|
||||
// literal y kernel + userspace tienen que coincidir aunque el
|
||||
// catalogo crezca. Este test es el contrato.
|
||||
assert_eq!(CodigoError::Ok.como_i32(), 0);
|
||||
assert_eq!(CodigoError::Ausente.como_i32(), -1);
|
||||
assert_eq!(CodigoError::CapacidadInsuficiente.como_i32(), -2);
|
||||
assert_eq!(CodigoError::AlmacenamientoFallo.como_i32(), -3);
|
||||
assert_eq!(CodigoError::SinFoco.como_i32(), -4);
|
||||
assert_eq!(CodigoError::EnvioFallo.como_i32(), -5);
|
||||
assert_eq!(CodigoError::Saturado.como_i32(), -6);
|
||||
assert_eq!(CodigoError::PayloadInvalido.como_i32(), -7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idioma_iso639_empaqueta_en_little_endian() {
|
||||
// `es` => 'e' (0x65) en el byte bajo, 's' (0x73) en el alto.
|
||||
assert_eq!(idioma_iso639(*b"es"), 0x7365);
|
||||
assert_eq!(idioma_iso639(*b"en"), 0x6E65);
|
||||
assert_eq!(idioma_iso639(*b"qu"), 0x7571);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canal_ida_y_vuelta_con_dos_raices() {
|
||||
let canal = Canal {
|
||||
version: VERSION_CANAL,
|
||||
nombre: String::from("estable"),
|
||||
autor: [0xA1; 32],
|
||||
raices: vec![
|
||||
RaizFirmada {
|
||||
timestamp: 1_700_000_000,
|
||||
raiz_manifiesto: [0x11; 32],
|
||||
firma: [0x22; 64],
|
||||
},
|
||||
RaizFirmada {
|
||||
timestamp: 1_700_000_100,
|
||||
raiz_manifiesto: [0x33; 32],
|
||||
firma: [0x44; 64],
|
||||
},
|
||||
],
|
||||
};
|
||||
let bytes = canal.serializar().unwrap();
|
||||
let recuperado = Canal::deserializar(&bytes).unwrap();
|
||||
assert_eq!(recuperado, canal);
|
||||
// `vigente` devuelve la ultima entrada por orden, no la mas reciente
|
||||
// por timestamp — el contrato es que las entradas vienen ordenadas;
|
||||
// verificarlo es responsabilidad de quien construye el canal.
|
||||
assert_eq!(recuperado.vigente().unwrap().raiz_manifiesto, [0x33; 32]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canal_rechaza_version_y_nombre_excedido() {
|
||||
let mut canal = Canal {
|
||||
version: 99,
|
||||
nombre: String::from("dev"),
|
||||
autor: [0; 32],
|
||||
raices: Vec::new(),
|
||||
};
|
||||
let bytes = postcard::to_allocvec(&canal).unwrap();
|
||||
assert!(Canal::deserializar(&bytes).is_err());
|
||||
canal.version = VERSION_CANAL;
|
||||
assert!(Canal::deserializar(&canal.serializar().unwrap()).is_ok());
|
||||
|
||||
// Nombre excedido: el serializador lo veta sin escribir nada al disco.
|
||||
let largo = Canal {
|
||||
version: VERSION_CANAL,
|
||||
nombre: "x".repeat(NOMBRE_CANAL_LIMITE + 1),
|
||||
autor: [0; 32],
|
||||
raices: Vec::new(),
|
||||
};
|
||||
assert!(largo.serializar().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mensaje_a_firmar_es_canonico_y_distingue_canales() {
|
||||
let raiz: Hash = [0x55; 32];
|
||||
let m1 = mensaje_a_firmar("estable", 42, &raiz);
|
||||
let m2 = mensaje_a_firmar("estable", 42, &raiz);
|
||||
assert_eq!(m1, m2, "el mensaje firmable debe ser deterministico");
|
||||
|
||||
// Cambiar el canal cambia el mensaje: una firma valida en `dev` no se
|
||||
// replica en `estable`.
|
||||
let m3 = mensaje_a_firmar("dev", 42, &raiz);
|
||||
assert_ne!(m1, m3);
|
||||
|
||||
// Cambiar el timestamp tambien — no se replica una recomendacion vieja
|
||||
// como si fuera nueva.
|
||||
let m4 = mensaje_a_firmar("estable", 43, &raiz);
|
||||
assert_ne!(m1, m4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mensaje_capacidad_es_canonico_y_distingue_bytecode_y_permisos() {
|
||||
let bc: Hash = [0xAB; 32];
|
||||
let m1 = mensaje_capacidad(&bc, PERMISO_RED);
|
||||
assert_eq!(m1, mensaje_capacidad(&bc, PERMISO_RED), "deterministico");
|
||||
// Layout: bytecode(32) || permisos_le(4).
|
||||
assert_eq!(&m1[..32], &bc);
|
||||
assert_eq!(&m1[32..], &PERMISO_RED.to_le_bytes());
|
||||
|
||||
// Distinto bytecode => distinto mensaje: una concesion no se transplanta.
|
||||
let otro: Hash = [0xCD; 32];
|
||||
assert_ne!(m1, mensaje_capacidad(&otro, PERMISO_RED));
|
||||
// Distintos permisos => distinto mensaje: subir un bit invalida la firma.
|
||||
assert_ne!(m1, mensaje_capacidad(&bc, PERMISO_RED | PERMISO_RAIZ));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_revocacion_roundtrip_y_rechaza_version_ajena() {
|
||||
let overlay = OverlayRevocacion {
|
||||
version: VERSION_OVERLAY,
|
||||
revocaciones: vec![RevocacionFirmada {
|
||||
objetivo: [0x42; 32],
|
||||
motivo: 0, // Compromised
|
||||
emitida_en: 1_700_000_000,
|
||||
vence_en: None,
|
||||
firmantes: vec![
|
||||
FirmaRevocacion { autor: [0x10; 32], firma: [0xAA; 64] },
|
||||
FirmaRevocacion { autor: [0x11; 32], firma: [0xBB; 64] },
|
||||
],
|
||||
}],
|
||||
};
|
||||
let bytes = overlay.serializar().unwrap();
|
||||
let leido = OverlayRevocacion::deserializar(&bytes).unwrap();
|
||||
assert_eq!(leido, overlay);
|
||||
assert_eq!(leido.revocaciones[0].firmantes.len(), 2);
|
||||
|
||||
// Un overlay con versión ajena se rechaza, no se malinterpreta.
|
||||
let ajeno = OverlayRevocacion { version: 99, revocaciones: Vec::new() };
|
||||
let bytes = postcard::to_allocvec(&ajeno).unwrap();
|
||||
assert!(OverlayRevocacion::deserializar(&bytes).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mensaje_rotacion_clave_layout_y_dominio() {
|
||||
let vieja = [0x11; 32];
|
||||
let nueva = [0x22; 32];
|
||||
let m = mensaje_rotacion_clave(&vieja, &nueva, 0x0A0B0C0D);
|
||||
// Layout: DOM || old(32) || new(32) || issued_at_le(8).
|
||||
assert_eq!(&m[..DOM_ROTACION_CLAVE.len()], DOM_ROTACION_CLAVE);
|
||||
let p = DOM_ROTACION_CLAVE.len();
|
||||
assert_eq!(&m[p..p + 32], &vieja);
|
||||
assert_eq!(&m[p + 32..p + 64], &nueva);
|
||||
assert_eq!(&m[p + 64..], &0x0A0B0C0Du64.to_le_bytes());
|
||||
// Distinto timestamp => distinto canonico (no se revive una rotacion vieja).
|
||||
assert_ne!(m, mensaje_rotacion_clave(&vieja, &nueva, 0x0A0B0C0E));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mensaje_revocacion_clave_distingue_motivo_y_no_colisiona_none_some_cero() {
|
||||
let target = [0x99; 32];
|
||||
// El motivo entra en el canonico: no se "asciende" un retiro a compromiso.
|
||||
let comprometida = mensaje_revocacion_clave(&target, 0, 5, None);
|
||||
let retirada = mensaje_revocacion_clave(&target, 1, 5, None);
|
||||
assert_ne!(comprometida, retirada);
|
||||
// Layout permanente: DOM || target(32) || [motivo] || issued_le(8) || 0.
|
||||
let p = DOM_REVOCACION_CLAVE.len();
|
||||
assert_eq!(&comprometida[..p], DOM_REVOCACION_CLAVE);
|
||||
assert_eq!(&comprometida[p..p + 32], &target);
|
||||
assert_eq!(comprometida[p + 32], 0u8);
|
||||
assert_eq!(&comprometida[p + 33..p + 41], &5u64.to_le_bytes());
|
||||
assert_eq!(*comprometida.last().unwrap(), 0u8); // tag None
|
||||
// `None` y `Some(0)` no colisionan: el tag los separa.
|
||||
let none = mensaje_revocacion_clave(&target, 1, 5, None);
|
||||
let some_cero = mensaje_revocacion_clave(&target, 1, 5, Some(0));
|
||||
assert_ne!(none, some_cero);
|
||||
assert_eq!(*some_cero.last().unwrap(), 0u8); // ultimo byte de 0u64 LE
|
||||
assert_eq!(some_cero[p + 41], 1u8); // tag Some
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concesion_capacidad_roundtrip() {
|
||||
let c = ConcesionCapacidad {
|
||||
bytecode: [0x11; 32],
|
||||
permisos: PERMISO_RED | PERMISO_RAIZ,
|
||||
autor: [0x22; 32],
|
||||
firma: [0x33; 64],
|
||||
};
|
||||
let bytes = c.serializar().unwrap();
|
||||
let vuelta = ConcesionCapacidad::deserializar(&bytes).unwrap();
|
||||
assert_eq!(c, vuelta);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permisos_efectivos_es_la_interseccion() {
|
||||
// El manifiesto pide RED|RAIZ pero la concesion solo autoriza RED:
|
||||
// efectivos = RED. El manifiesto no puede escalar a RAIZ por su cuenta.
|
||||
let declarados = PERMISO_RED | PERMISO_RAIZ;
|
||||
let concedidos = PERMISO_RED;
|
||||
assert_eq!(permisos_efectivos(declarados, concedidos), PERMISO_RED);
|
||||
// Concesion generosa, manifiesto modesto: efectivos = lo que el
|
||||
// manifiesto pidio (no enciende lo que no se declaro).
|
||||
assert_eq!(
|
||||
permisos_efectivos(PERMISO_RED, PERMISO_RED | PERMISO_ALTAVOZ),
|
||||
PERMISO_RED
|
||||
);
|
||||
// Sin concesion (concedidos=0): cero capacidades gateadas.
|
||||
assert_eq!(permisos_efectivos(declarados, 0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superbloque_cabe_en_un_sector_y_vuelve_intacto() {
|
||||
let sb = SuperBloque {
|
||||
magia: MAGIA,
|
||||
version: VERSION_SUPERBLOQUE,
|
||||
log_inicio: 1,
|
||||
cursor: 4096,
|
||||
raiz: Some([1u8; 32]),
|
||||
manifiesto: Some([2u8; 32]),
|
||||
};
|
||||
let bytes = sb.serializar().unwrap();
|
||||
assert!(bytes.len() <= TAM_SECTOR);
|
||||
assert_eq!(SuperBloque::deserializar(&bytes).unwrap(), sb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wawa_ecosystem_immutable_vanguard() {
|
||||
// =====================================================================
|
||||
// FASE 50 :: VANGUARDIA INMUTABLE DEL ABI WAWA
|
||||
// ---------------------------------------------------------------------
|
||||
// Sello de cierre del Manifiesto Tecnico. La firma numerica de las
|
||||
// ocho variantes licitas de `CodigoError` —el lenguaje compartido
|
||||
// entre el kernel Ring 0, los modulos WASM Ring 3 y el explorador
|
||||
// host-side— ha quedado fijada. Este test la consagra:
|
||||
//
|
||||
// * Cada variante tiene su valor i32 FIJO en el orden negociado
|
||||
// a lo largo de las primeras 49 fases. Renumerar una existente
|
||||
// seria romper, byte a byte, todo binario Ring 3 ya inscrito
|
||||
// en el grafo direccionado por contenido.
|
||||
//
|
||||
// * La conversion `as i32` y la `const fn como_i32` son gemelas:
|
||||
// ambas extraen el discriminante `#[repr(i32)]` —sin trampa,
|
||||
// sin tabla auxiliar—.
|
||||
//
|
||||
// * El catalogo permanece de cardinalidad ocho: ni una variante
|
||||
// menos (siempre Ok=0 + siete fallas controladas), ni una mas
|
||||
// escondida tras renumeracion. Anadir una NUEVA codifica un
|
||||
// valor entero NUEVO; el contrato no se rompe.
|
||||
//
|
||||
// Quien pretenda extender el catalogo en una fase futura debera,
|
||||
// ANTES de mover una variante, actualizar esta tabla de cierre
|
||||
// y aceptar que el wire del ecosistema entero ha cambiado de era.
|
||||
// =====================================================================
|
||||
|
||||
// 1. Firma numerica congelada de la vanguardia (Ok + 7 fallas).
|
||||
const VANGUARDIA: [(CodigoError, i32); 8] = [
|
||||
(CodigoError::Ok, 0),
|
||||
(CodigoError::Ausente, -1),
|
||||
(CodigoError::CapacidadInsuficiente, -2),
|
||||
(CodigoError::AlmacenamientoFallo, -3),
|
||||
(CodigoError::SinFoco, -4),
|
||||
(CodigoError::EnvioFallo, -5),
|
||||
(CodigoError::Saturado, -6),
|
||||
(CodigoError::PayloadInvalido, -7),
|
||||
];
|
||||
for &(variante, valor) in VANGUARDIA.iter() {
|
||||
assert_eq!(
|
||||
variante.como_i32(),
|
||||
valor,
|
||||
"ABI roto: {:?} dejo de valer {} — mutacion accidental detectada",
|
||||
variante,
|
||||
valor,
|
||||
);
|
||||
// `as i32` directo: el `#[repr(i32)]` fija el discriminante en
|
||||
// ambos caminos —el const fn y el cast— sin tabla auxiliar.
|
||||
assert_eq!(variante as i32, valor);
|
||||
}
|
||||
|
||||
// 2. La proyeccion debe ser inyectiva: dos variantes distintas no
|
||||
// pueden compartir su valor i32 — el catalogo de la vanguardia
|
||||
// no tolera colisiones.
|
||||
for i in 0..VANGUARDIA.len() {
|
||||
for j in (i + 1)..VANGUARDIA.len() {
|
||||
assert_ne!(
|
||||
VANGUARDIA[i].1, VANGUARDIA[j].1,
|
||||
"ABI roto: dos variantes comparten valor i32"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Cardinalidad inmutable: 1 (Ok) + 7 fallas controladas. Cualquier
|
||||
// fase que pretenda crecer este catalogo debe actualizar el test
|
||||
// explicitamente; un cambio silencioso se delata aqui.
|
||||
assert_eq!(
|
||||
VANGUARDIA.len(),
|
||||
8,
|
||||
"ABI roto: cardinalidad del catalogo CodigoError mutada"
|
||||
);
|
||||
|
||||
// 4. Rango cerrado de fallas en [-7, -1]. La cascada de Pluma
|
||||
// (apps/pluma) y el dispatcher Ring 0 cuentan con este rango
|
||||
// EXACTO para distinguir codigos de error de retornos legitimos.
|
||||
let fallas_min = VANGUARDIA.iter().skip(1).map(|&(_, v)| v).min().unwrap();
|
||||
let fallas_max = VANGUARDIA.iter().skip(1).map(|&(_, v)| v).max().unwrap();
|
||||
assert_eq!(fallas_min, -7, "ABI roto: el suelo de fallas se desplazo");
|
||||
assert_eq!(fallas_max, -1, "ABI roto: el techo de fallas se desplazo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superbloque_porta_log_inicio_distinto_de_uno() {
|
||||
// Tras una compactacion semantica, `log_inicio` no es 1: apunta al
|
||||
// sector donde empieza el segmento limpio recien escrito. El
|
||||
// superbloque sigue cabiendo en su sector y el roundtrip preserva
|
||||
// el campo: el GC depende de esa simetria.
|
||||
let sb = SuperBloque {
|
||||
magia: MAGIA,
|
||||
version: VERSION_SUPERBLOQUE,
|
||||
log_inicio: 32_768,
|
||||
cursor: 33_500,
|
||||
raiz: Some([0xAA; 32]),
|
||||
manifiesto: Some([0xBB; 32]),
|
||||
};
|
||||
let bytes = sb.serializar().unwrap();
|
||||
assert!(bytes.len() <= TAM_SECTOR);
|
||||
let leido = SuperBloque::deserializar(&bytes).unwrap();
|
||||
assert_eq!(leido.log_inicio, 32_768);
|
||||
assert_eq!(leido.cursor, 33_500);
|
||||
}
|
||||
|
||||
// === Fase 60: MensajeAsistente ===
|
||||
|
||||
#[test]
|
||||
fn mensaje_asistente_consulta_ida_y_vuelta() {
|
||||
let msg = MensajeAsistente::Consulta {
|
||||
id: 0xDEADBEEF,
|
||||
prompt: "lanza pluma".into(),
|
||||
contexto: Contexto {
|
||||
apps: vec!["pluma".into(), "bitacora".into()],
|
||||
manifiesto_actual: Some([0x11; 32]),
|
||||
configuracion_activa: None,
|
||||
},
|
||||
};
|
||||
let bytes = msg.serializar().unwrap();
|
||||
let leido = MensajeAsistente::deserializar(&bytes).unwrap();
|
||||
assert_eq!(leido, msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mensaje_asistente_propuesta_lanzar_app() {
|
||||
let msg = MensajeAsistente::Propuesta {
|
||||
id: 42,
|
||||
accion: AccionPropuesta::LanzarApp { plantilla: 7 },
|
||||
explicacion: "abre pluma para tomar notas".into(),
|
||||
confianza: 0.95,
|
||||
};
|
||||
let bytes = msg.serializar().unwrap();
|
||||
let leido = MensajeAsistente::deserializar(&bytes).unwrap();
|
||||
assert_eq!(leido, msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mensaje_asistente_propuesta_instalar_app() {
|
||||
let msg = MensajeAsistente::Propuesta {
|
||||
id: 100,
|
||||
accion: AccionPropuesta::InstalarApp {
|
||||
manifiesto_propuesto: [0xAB; 32],
|
||||
},
|
||||
explicacion: "manifiesto v2 firmado".into(),
|
||||
confianza: 1.0,
|
||||
};
|
||||
let bytes = msg.serializar().unwrap();
|
||||
let leido = MensajeAsistente::deserializar(&bytes).unwrap();
|
||||
assert_eq!(leido, msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mensaje_asistente_error_ida_y_vuelta() {
|
||||
let msg = MensajeAsistente::Error {
|
||||
id: 0,
|
||||
motivo: "LLM rate-limited".into(),
|
||||
};
|
||||
let bytes = msg.serializar().unwrap();
|
||||
let leido = MensajeAsistente::deserializar(&bytes).unwrap();
|
||||
assert_eq!(leido, msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mensaje_asistente_basura_rechazada() {
|
||||
// Bytes arbitrarios — postcard debe rechazar sin panic.
|
||||
let basura = [0xFFu8; 16];
|
||||
assert!(MensajeAsistente::deserializar(&basura).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mensaje_asistente_propuesta_notar_sin_efecto() {
|
||||
// `Notar` permite respuestas informativas: el LLM contesta una
|
||||
// pregunta sin proponer una accion ejecutable.
|
||||
let msg = MensajeAsistente::Propuesta {
|
||||
id: 1,
|
||||
accion: AccionPropuesta::Notar {
|
||||
texto: "tienes 3 apps abiertas en el escritorio 1".into(),
|
||||
},
|
||||
explicacion: String::new(),
|
||||
confianza: 1.0,
|
||||
};
|
||||
let bytes = msg.serializar().unwrap();
|
||||
let leido = MensajeAsistente::deserializar(&bytes).unwrap();
|
||||
assert_eq!(leido, msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canal_asistente_no_choca_con_otros() {
|
||||
// 0x4153 = "AS". Si más adelante se registran otros canales
|
||||
// (chasqui, agora, etc.) este test recuerda el namespace
|
||||
// ocupado. Cambiar el valor requiere actualizar el doc.
|
||||
assert_eq!(CANAL_ASISTENTE, 0x4153);
|
||||
assert_eq!(&CANAL_ASISTENTE.to_be_bytes(), b"AS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ethertype_asistente_distinto_de_akasha() {
|
||||
// El demuxer Akasha del kernel descarta payloads que no parsean
|
||||
// como `MensajeAkasha`. Si usaramos 0x88B5, los frames del
|
||||
// asistente caerian como `PayloadInvalido` y se contarian en
|
||||
// `RX_DESCARTADOS` antes de pasar al usuario. Con 0x88B6 caen
|
||||
// en la rama `EtherTypeAjeno` que va directo al usuario.
|
||||
assert_eq!(ETHERTYPE_ASISTENTE, 0x88B6);
|
||||
assert_ne!(ETHERTYPE_ASISTENTE, 0x88B5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cabecera_cable_round_trip_consulta() {
|
||||
let mut buf = [0u8; 32];
|
||||
let n = escribir_cabecera_cable(&mut buf, TipoCable::Consulta, 0xDEADBEEFCAFEBABE)
|
||||
.expect("cabe");
|
||||
assert_eq!(n, TAM_CABECERA_CABLE);
|
||||
let (tipo, id) = leer_cabecera_cable(&buf).expect("valida");
|
||||
assert_eq!(tipo, TipoCable::Consulta);
|
||||
assert_eq!(id, 0xDEADBEEFCAFEBABE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cabecera_cable_round_trip_propuesta_lanzar() {
|
||||
let mut buf = [0u8; 12];
|
||||
escribir_cabecera_cable(&mut buf, TipoCable::PropuestaLanzarApp, 7).unwrap();
|
||||
let (tipo, id) = leer_cabecera_cable(&buf).unwrap();
|
||||
assert_eq!(tipo, TipoCable::PropuestaLanzarApp);
|
||||
assert_eq!(id, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cabecera_cable_rechaza_canal_ajeno() {
|
||||
let mut buf = [0u8; 12];
|
||||
// Forjamos una cabecera con canal distinto al asistente.
|
||||
buf[0..2].copy_from_slice(&0xABCDu16.to_be_bytes());
|
||||
buf[2..4].copy_from_slice(&(TipoCable::Consulta as u16).to_be_bytes());
|
||||
assert!(leer_cabecera_cable(&buf).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cabecera_cable_rechaza_tipo_desconocido() {
|
||||
let mut buf = [0u8; 12];
|
||||
buf[0..2].copy_from_slice(&CANAL_ASISTENTE.to_be_bytes());
|
||||
buf[2..4].copy_from_slice(&999u16.to_be_bytes()); // tipo inválido
|
||||
assert!(leer_cabecera_cable(&buf).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cabecera_cable_rechaza_truncada() {
|
||||
let buf = [0u8; 5];
|
||||
assert!(leer_cabecera_cable(&buf).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escribir_cabecera_cable_rechaza_buffer_corto() {
|
||||
let mut buf = [0u8; 5];
|
||||
assert!(escribir_cabecera_cable(&mut buf, TipoCable::Consulta, 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tipo_cable_codigos_estables() {
|
||||
// Si alguien renumera los discriminantes, los lectores
|
||||
// binarios viejos rompen. Este test caza el cambio.
|
||||
assert_eq!(TipoCable::Consulta as u16, 1);
|
||||
assert_eq!(TipoCable::PropuestaNotar as u16, 2);
|
||||
assert_eq!(TipoCable::PropuestaLanzarApp as u16, 3);
|
||||
assert_eq!(TipoCable::PropuestaInstalarApp as u16, 4);
|
||||
assert_eq!(TipoCable::PropuestaCambiarConfig as u16, 5);
|
||||
assert_eq!(TipoCable::Error as u16, 6);
|
||||
assert_eq!(TipoCable::RequestFirma as u16, 7);
|
||||
assert_eq!(TipoCable::Firma as u16, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cabecera_cable_round_trip_request_firma() {
|
||||
// Fase 60 v4 :: la app pide firma humana. Round-trip por la
|
||||
// misma puerta — el `id` corresponde al de la propuesta original.
|
||||
let mut buf = [0u8; 12];
|
||||
escribir_cabecera_cable(&mut buf, TipoCable::RequestFirma, 99).unwrap();
|
||||
let (tipo, id) = leer_cabecera_cable(&buf).unwrap();
|
||||
assert_eq!(tipo, TipoCable::RequestFirma);
|
||||
assert_eq!(id, 99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cabecera_cable_round_trip_firma() {
|
||||
let mut buf = [0u8; 12];
|
||||
escribir_cabecera_cable(&mut buf, TipoCable::Firma, 99).unwrap();
|
||||
let (tipo, id) = leer_cabecera_cable(&buf).unwrap();
|
||||
assert_eq!(tipo, TipoCable::Firma);
|
||||
assert_eq!(id, 99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tipo_objeto_codigos_estables() {
|
||||
// El primer byte del payload de RequestFirma. La app wasm y
|
||||
// el puente leen estos numeros literalmente — renumerarlos
|
||||
// rompe el cable.
|
||||
assert_eq!(TIPO_OBJETO_CUADERNO, 1);
|
||||
assert_eq!(TIPO_OBJETO_CONFIGURACION, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tipo_cable_de_u16_acepta_nuevos() {
|
||||
assert_eq!(TipoCable::de_u16(7), Some(TipoCable::RequestFirma));
|
||||
assert_eq!(TipoCable::de_u16(8), Some(TipoCable::Firma));
|
||||
assert_eq!(TipoCable::de_u16(9), None);
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
use super::*;
|
||||
|
||||
// =============================================================================
|
||||
// Los tipos del grafo
|
||||
// =============================================================================
|
||||
|
||||
/// Un objeto del grafo: una carga util opaca y las aristas que lo enlazan con
|
||||
/// otros objetos. Los `hijos` hacen del almacen un DAG —no un arbol—: un
|
||||
/// objeto puede ser hijo de muchos, y el direccionamiento por contenido
|
||||
/// garantiza que cada contenido distinto se guarda una sola vez.
|
||||
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
|
||||
pub struct Objeto {
|
||||
/// La carga util del objeto: bytes crudos, que nadie interpreta aqui.
|
||||
pub datos: Vec<u8>,
|
||||
/// Los hashes de los objetos hijos: las aristas salientes del DAG.
|
||||
pub hijos: Vec<Hash>,
|
||||
}
|
||||
|
||||
/// El superbloque: el sector 0 del disco. Ancla el grafo entero — dice donde
|
||||
/// arranca el log activo, donde acaba, cual es el objeto raiz y cual el
|
||||
/// manifiesto.
|
||||
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
|
||||
pub struct SuperBloque {
|
||||
/// Firma magica: debe ser [`MAGIA`].
|
||||
pub magia: [u8; 8],
|
||||
/// Version del format: debe ser [`VERSION_SUPERBLOQUE`].
|
||||
pub version: u32,
|
||||
/// Primer sector del log activo. En un disco virgen es `1` (justo despues
|
||||
/// del superbloque); el compactador semantico (Fase 24) lo desplaza al
|
||||
/// principio de un segmento limpio cada vez que aspira los nodos muertos.
|
||||
/// Mover `log_inicio` (junto con `cursor`) en una sola escritura del
|
||||
/// superbloque es lo que convierte la compactacion en una transicion
|
||||
/// atomica: el log viejo queda en sectores anteriores, ya inalcanzables,
|
||||
/// pero el grafo logico es el mismo.
|
||||
pub log_inicio: u64,
|
||||
/// Proximo sector libre del log — donde se anexara el siguiente objeto.
|
||||
pub cursor: u64,
|
||||
/// El objeto raiz del DAG: el punto de entrada que el userspace fija y lee.
|
||||
pub raiz: Option<Hash>,
|
||||
/// El Manifiesto de Genesis: el objeto que dicta que apps nacen del grafo
|
||||
/// al arrancar. Ancla del kernel, gemela de `raiz` (del userspace).
|
||||
pub manifiesto: Option<Hash>,
|
||||
}
|
||||
|
||||
/// El Manifiesto de Genesis: la lista de aplicaciones que el kernel instancia
|
||||
/// al arrancar. Vive como un objeto del grafo; el superbloque guarda su hash.
|
||||
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
|
||||
pub struct Manifiesto {
|
||||
/// Version del format — debe ser [`VERSION_MANIFIESTO`].
|
||||
pub version: u32,
|
||||
/// Las aplicaciones del userspace, en orden de arranque.
|
||||
pub apps: Vec<EntradaApp>,
|
||||
/// Hash del nodo `Configuracion` activo (idioma + tema). `None` => el
|
||||
/// kernel emplea los valores por defecto. Cambiar de idioma o tema NO
|
||||
/// muta este nodo: engendra una `Configuracion` nueva, calcula su hash,
|
||||
/// y reancla el manifiesto al objeto nuevo en un solo paso atomico —el
|
||||
/// mismo trazado que `EntradaApp::estado` para el estado por app.
|
||||
pub configuracion: Option<Hash>,
|
||||
/// Hash del nodo [`OverlayRevocacion`] vigente, o `None` si el operador no
|
||||
/// ancló ninguno (el caso común — sin revocaciones de claves del anillo).
|
||||
/// El kernel lo lee FRESH en el arranque y deniega en `autor_en_anillo` toda
|
||||
/// clave del anillo revocada M-of-N: así una clave soberana filtrada se
|
||||
/// apaga ENTRE reflasheos, sin esperar al re-forjado del binario. Es la pieza
|
||||
/// del plano de CONTROL del SDD-rotacion-revocacion §4 — gemela de
|
||||
/// `configuracion`/`estado`: reanclar engendra un overlay nuevo y mueve el
|
||||
/// puntero del manifiesto, jamás muta en sitio.
|
||||
pub overlay_revocacion: Option<Hash>,
|
||||
/// Hash del nodo del **marco del escritorio** (`pata`) activo: un
|
||||
/// `pata_core::wire::WireConfig` serializado con postcard. `None` => el
|
||||
/// kernel emplea el marco por defecto. Gemelo de `configuracion`/
|
||||
/// `overlay_revocacion`: proponer un marco nuevo (capacidad WASM
|
||||
/// `sys_marco_proponer`) engendra un nodo nuevo, calcula su hash y reancla el
|
||||
/// manifiesto al objeto nuevo —jamás muta en sitio—, así el marco sobrevive
|
||||
/// al reinicio. `format` sólo transporta el `Hash`; el (de)serializado del
|
||||
/// `WireConfig` lo hace `pata-core`, no este crate.
|
||||
pub marco: Option<Hash>,
|
||||
}
|
||||
|
||||
/// Un idioma codificado como un par de letras ASCII ISO 639-1 empaquetado en
|
||||
/// little-endian: `b'e' | (b's' << 8) == 0x7365` para castellano, `0x6E65`
|
||||
/// para ingles, `0x7571` para quechua. El propio numero es trivialmente
|
||||
/// legible al inspeccionarlo en hexadecimal —no hace falta una tabla—.
|
||||
pub type IdiomaCodigo = u16;
|
||||
|
||||
/// Compone un `IdiomaCodigo` desde un par ISO 639-1 (`b"es"`, `b"qu"`...).
|
||||
/// Las dos letras viajan en orden de lectura: la primera ocupa el byte bajo.
|
||||
pub const fn idioma_iso639(letras: [u8; 2]) -> IdiomaCodigo {
|
||||
(letras[0] as u16) | ((letras[1] as u16) << 8)
|
||||
}
|
||||
|
||||
/// Codigo de idioma por defecto: `es` (castellano). Lo emplea el kernel cuando
|
||||
/// el manifiesto no enlaza ninguna `Configuracion`.
|
||||
pub const IDIOMA_DEFECTO: IdiomaCodigo = idioma_iso639(*b"es");
|
||||
|
||||
/// La paleta de un tema visual: cinco colores RGBA8 — primario, secundario,
|
||||
/// fondo, texto, acento— en ese orden. La forma binaria (20 bytes) es la
|
||||
/// misma que la app recibe del kernel a traves de la capacidad pasiva
|
||||
/// `sys_config_paleta`. Cinco colores cubren un esquema completo sin caer en
|
||||
/// la trampa de "un color por widget": la consistencia visual la impone el
|
||||
/// numero pequeño.
|
||||
pub type Paleta = [u8; 20];
|
||||
|
||||
/// Paleta por defecto cuando el manifiesto no enlaza configuracion. Negro de
|
||||
/// fondo, blanco de texto, azul renaser de acento; cualquier app pinta sin
|
||||
/// adivinar. Cada cuatro bytes son R, G, B, A en ese orden.
|
||||
pub const PALETA_DEFECTO: Paleta = [
|
||||
0x20, 0x80, 0xC0, 0xFF, // primario — azul renaser
|
||||
0x60, 0x60, 0x60, 0xFF, // secundario — gris medio
|
||||
0x00, 0x00, 0x00, 0xFF, // fondo — negro
|
||||
0xFF, 0xFF, 0xFF, 0xFF, // texto — blanco
|
||||
0xF0, 0x90, 0x20, 0xFF, // acento — ambar
|
||||
];
|
||||
|
||||
/// La configuracion activa de Wawa: idioma + paleta del tema. Es un objeto
|
||||
/// del grafo —direccionado por su hash—; el manifiesto la enlaza. Cambiar de
|
||||
/// idioma o tema significa engendrar UN NODO NUEVO y reanclar el manifiesto
|
||||
/// al hash del nuevo objeto en una sola transicion atomica. Sin estados
|
||||
/// mutables globales: la "configuracion vigente" es siempre el hash al que
|
||||
/// apunta el manifiesto en este preciso fotograma.
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub struct Configuracion {
|
||||
/// Version del format — debe ser [`VERSION_CONFIGURACION`].
|
||||
pub version: u32,
|
||||
/// Idioma activo (ISO 639-1 empaquetado, ver [`idioma_iso639`]).
|
||||
pub idioma: IdiomaCodigo,
|
||||
/// Paleta del tema visual: cinco colores RGBA8 en orden canonico.
|
||||
pub paleta: Paleta,
|
||||
}
|
||||
|
||||
impl Configuracion {
|
||||
/// La configuracion canonica cuando el manifiesto no enlaza ninguna:
|
||||
/// idioma `es`, paleta `PALETA_DEFECTO`. El kernel la inyecta tal cual en
|
||||
/// el `ContextoCapacidades` de cada app.
|
||||
pub const fn por_defecto() -> Configuracion {
|
||||
Configuracion {
|
||||
version: VERSION_CONFIGURACION,
|
||||
idioma: IDIOMA_DEFECTO,
|
||||
paleta: PALETA_DEFECTO,
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializa la configuracion a su forma binaria `postcard`.
|
||||
pub fn serializar(&self) -> Result<Vec<u8>, &'static str> {
|
||||
postcard::to_allocvec(self).map_err(|_| "configuracion :: serializacion fallida")
|
||||
}
|
||||
|
||||
/// Reconstruye una configuracion desde la carga util de su objeto. Rechaza
|
||||
/// una version desconocida en lugar de malinterpretarla — gemelo del trato
|
||||
/// que `Manifiesto::deserializar` da a su propia version.
|
||||
pub fn deserializar(bytes: &[u8]) -> Result<Configuracion, &'static str> {
|
||||
let (cfg, _) = postcard::take_from_bytes::<Configuracion>(bytes)
|
||||
.map_err(|_| "configuracion :: deserializacion fallida")?;
|
||||
if cfg.version != VERSION_CONFIGURACION {
|
||||
return Err("configuracion :: version de format desconocida");
|
||||
}
|
||||
Ok(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bitfield de permisos de una app — cada bit habilita una clase de
|
||||
/// capacidades. Capacidades sensibles que no figuran aqui no se ENLAZAN en
|
||||
/// el `Linker` de wasmi cuando la app se instancia: el import del modulo
|
||||
/// queda sin resolver y el modulo entero ni siquiera arranca. La frontera
|
||||
/// es fisica; el kernel no hace chequeos en cada syscall porque no hay
|
||||
/// syscall que chequear: la funcion del host no se concedio. POSIX gestiona
|
||||
/// privilegios con un check `if (uid == 0)` en cada syscall y se llena de
|
||||
/// CVE; aqui no hay nada que comprobar.
|
||||
pub type Permisos = u32;
|
||||
|
||||
/// Permite enviar y recibir frames Ethernet y solicitar objetos por hash a
|
||||
/// peers Akasha. Sin este bit, las capacidades `sys_net_*` y `sys_red_*` no
|
||||
/// se enlazan: el modulo no las puede invocar porque no existen.
|
||||
pub const PERMISO_RED: Permisos = 1 << 0;
|
||||
|
||||
/// Permite grabar objetos nuevos en el grafo del disco (`sys_object_put`).
|
||||
/// La lectura del grafo es libre —la inmutabilidad direccionada por contenido
|
||||
/// la hace inofensiva—, la escritura no.
|
||||
pub const PERMISO_GRAFO_ESCRITURA: Permisos = 1 << 1;
|
||||
|
||||
/// Permite reanclar la raiz del grafo (`sys_object_fijar_raiz`). Cambia el
|
||||
/// punto de entrada que el resto del userspace lee; un permiso de mucha
|
||||
/// gravedad.
|
||||
pub const PERMISO_RAIZ: Permisos = 1 << 2;
|
||||
|
||||
/// Permite hacer sonar la bocina del PC (`sys_tono`). El altavoz es un
|
||||
/// recurso unico y global; aunque ya esta gateado por foco, el bit deja
|
||||
/// explicito que la app puede SOLICITAR sonido.
|
||||
pub const PERMISO_ALTAVOZ: Permisos = 1 << 3;
|
||||
|
||||
/// Permite proponer una nueva `Configuracion` (`sys_config_proponer`):
|
||||
/// idioma + tema visual. La LECTURA pasiva del contexto (sys_config_idioma,
|
||||
/// sys_config_paleta) no necesita bit; cualquier app la tiene siempre.
|
||||
pub const PERMISO_CONFIG: Permisos = 1 << 4;
|
||||
|
||||
/// Permite forzar una pasada del compactador semantico del grafo
|
||||
/// (`sys_grafo_compactar`). El GC ya corre solo cuando
|
||||
/// `escrituras_pendientes() >= UMBRAL_GC` en el tic ocioso del compositor;
|
||||
/// este bit habilita la palanca explicita para `wawactl gc` y similares.
|
||||
/// Por su coste (toma el cerrojo del almacen y reescribe sectores), se
|
||||
/// asume reservado a apps de mantenimiento privilegiadas — no apto para
|
||||
/// userspace generico.
|
||||
pub const PERMISO_COMPACTAR: Permisos = 1 << 5;
|
||||
|
||||
/// Permite llamar al motor `tinkuy` embebido en el kernel: una sub-jaula
|
||||
/// `wasmi` aparte, con su propio Store y su propio fuel — la que carga
|
||||
/// `assets/tinkuy.wasm` y expone los `tk_*`. La capa de capacidades
|
||||
/// `sys_tinkuy_*` enlaza solo si el bit esta puesto. El motor tinkuy es
|
||||
/// computo puro (sin red, sin grafo, sin altavoz): el bit lo SE PARA del
|
||||
/// resto de capacidades, no porque sea privilegiado, sino porque tiene
|
||||
/// memoria persistente entre `tick`s — una app que lo tenga puede
|
||||
/// secuestrar slots de simulacion entre fotogramas y conviene que el
|
||||
/// operador lo declare a sabiendas.
|
||||
pub const PERMISO_TINKUY: Permisos = 1 << 6;
|
||||
|
||||
/// Una entrada del manifiesto: una aplicacion del userspace y todo lo que el
|
||||
/// kernel necesita para darle vida — su bytecode, su ventana, su cuota de
|
||||
/// memoria, su tabla de permisos y, si lo tuviera, su ultimo estado persistido.
|
||||
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
|
||||
pub struct EntradaApp {
|
||||
/// Nombre legible — para los rotulos de la consola y la baliza.
|
||||
pub nombre: String,
|
||||
/// Hash del objeto del grafo que contiene el bytecode WASM de la app.
|
||||
pub bytecode: Hash,
|
||||
/// Sub-region del framebuffer asignada a la app. Campos de ancho fijo
|
||||
/// `u32` A PROPOSITO: esto es un format EN DISCO. La `RegionPantalla` del
|
||||
/// kernel usa `usize` (ancho dependiente de plataforma) y no serializa.
|
||||
pub region_x: u32,
|
||||
pub region_y: u32,
|
||||
pub region_ancho: u32,
|
||||
pub region_alto: u32,
|
||||
/// Techo de memoria lineal de la app, en bytes. Cada app lleva su cuota.
|
||||
pub techo_memoria: u32,
|
||||
/// Presupuesto de combustible (unidades de wasmi) que la app recibe en
|
||||
/// cada `tick`. Es el techo TEMPORAL por fotograma: lo agota una app en
|
||||
/// bucle infinito (`SinCombustible`) y se desaloja. Por-app porque un
|
||||
/// editor con tree-sitter no necesita lo mismo que un reloj parpadeante;
|
||||
/// el scheduler cooperativo honra la declaracion en lugar de un techo unico.
|
||||
pub fuel_fotograma: u32,
|
||||
/// Hash del ultimo estado persistido de la app (Fase 7c). `None` hasta que
|
||||
/// la app guarde estado por primera vez.
|
||||
pub estado: Option<Hash>,
|
||||
/// Bitfield de permisos (ver [`Permisos`] y las constantes `PERMISO_*`).
|
||||
/// Lo evalua el `Linker` de wasmi al instanciar la app: las capacidades
|
||||
/// gateadas que no figuren aqui NO se registran. La app puede llamar a
|
||||
/// otras capacidades —la matriz pasiva siempre esta— pero las gateadas
|
||||
/// son, literalmente, simbolos inexistentes para el modulo.
|
||||
///
|
||||
/// Estos son los permisos DECLARADOS. Los EFECTIVOS (lo que el kernel
|
||||
/// enlaza de verdad) salen de [`permisos_efectivos`]`(permisos, concedidos)`
|
||||
/// donde `concedidos` viene de la [`ConcesionCapacidad`] referida por
|
||||
/// [`concesion`](Self::concesion). El manifiesto puede pedir menos, nunca mas.
|
||||
pub permisos: Permisos,
|
||||
/// Fase 67 / WAWA §14.1.3 — hash de la [`ConcesionCapacidad`] que firma el
|
||||
/// par `(bytecode, permisos)` de esta app, o `None`. La concesion vive como
|
||||
/// un objeto del grafo (direccionado por contenido); el kernel la recupera,
|
||||
/// verifica su firma contra el `AGORA_AUTH_RING` y toma la interseccion de
|
||||
/// sus permisos con los declarados aqui. `None` ⇒ sin techo per-bytecode:
|
||||
/// el kernel honra `permisos` tal cual (la integridad la da la firma del
|
||||
/// manifiesto). El binding "que binario puede que" queda asi INDEPENDIENTE
|
||||
/// del manifiesto: re-firmar un manifiesto no escala un binario por encima
|
||||
/// de su concesion.
|
||||
pub concesion: Option<Hash>,
|
||||
}
|
||||
|
||||
/// Un canal de release: un objeto del grafo que historiza, en orden cronologico,
|
||||
/// las raices de manifiesto que su(s) autor(es) recomiendan. Es el equivalente
|
||||
/// nativo de un repositorio apt/dnf/pacman, pero firmado por una `AgoraId` y no
|
||||
/// por una infraestructura central. Quien se suscribe a un canal confia en su
|
||||
/// autor; el canal nunca dice "esta es la unica version", dice "esta es mi
|
||||
/// recomendacion en este momento". El historial completo viaja junto.
|
||||
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
|
||||
pub struct Canal {
|
||||
/// Version del format — debe ser [`VERSION_CANAL`].
|
||||
pub version: u32,
|
||||
/// Nombre legible del canal: `estable`, `beta`, `dev`, `cofradia-tal`.
|
||||
/// Acotado a [`NOMBRE_CANAL_LIMITE`] bytes para que la cabecera sea barata.
|
||||
pub nombre: String,
|
||||
/// La identidad del autor que firma este canal. Quien recibe el canal
|
||||
/// verifica que cada `RaizFirmada` lleve una firma valida sobre esta clave.
|
||||
/// Un canal puede cambiar de autor en una version futura (multi-firma); por
|
||||
/// ahora, una clave gobierna un canal.
|
||||
pub autor: AgoraId,
|
||||
/// El historial de raices recomendadas, ordenado por `timestamp` ascendente.
|
||||
/// La ultima entrada es la recomendacion vigente. El historial completo se
|
||||
/// conserva para que un nodo pueda volver atras —rollback— sin pedirle
|
||||
/// permiso al canal.
|
||||
pub raices: Vec<RaizFirmada>,
|
||||
}
|
||||
|
||||
/// El sobre criptografico de un Manifiesto: empareja su hash BLAKE3 con la
|
||||
/// firma Ed25519 que un autor `AgoraId` produjo sobre el. Fase 25 — el
|
||||
/// kernel solo acepta una propuesta de reancla del manifiesto cuando llega
|
||||
/// envuelta en uno de estos sobres Y la firma valida contra la clave publica
|
||||
/// del usuario local que el binario del kernel lleva grabada. Sin firma
|
||||
/// valida, no hay mutacion: la mudanza de raiz es un PACTO MATEMATICO
|
||||
/// explicito, no una orden ciega de la red.
|
||||
///
|
||||
/// El mensaje que se firma es, literalmente, los 32 bytes de
|
||||
/// `manifiesto_hash` — el hash mismo es ya el resumen criptografico del
|
||||
/// payload del manifiesto, asi que firmar el hash equivale a firmar el
|
||||
/// manifiesto completo. Ed25519 no se preocupa por la longitud del mensaje.
|
||||
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
|
||||
pub struct ManifiestoFirmado {
|
||||
/// Hash BLAKE3 del Manifiesto propuesto. Un hash idiosincratico = un
|
||||
/// manifiesto idiosincratico — ningun atacante puede sustituirlo y
|
||||
/// reutilizar la firma sin reproducir el hash exacto.
|
||||
pub manifiesto_hash: Hash,
|
||||
/// Llave publica Ed25519 del autor que firma esta propuesta. El kernel
|
||||
/// la compara contra su clave local empotrada antes de molestarse en
|
||||
/// verificar la firma: una llave ajena cae con `CapacidadInsuficiente`
|
||||
/// sin gastar ciclos en criptografia.
|
||||
pub autor: AgoraId,
|
||||
/// Firma Ed25519 sobre los 32 bytes de `manifiesto_hash`.
|
||||
#[serde(with = "BigArray")]
|
||||
pub firma: Firma,
|
||||
}
|
||||
|
||||
impl ManifiestoFirmado {
|
||||
/// Serializa el sobre a su forma binaria `postcard` — la carga util del
|
||||
/// objeto del grafo que lo aloja (o el payload de un mensaje Akasha).
|
||||
pub fn serializar(&self) -> Result<Vec<u8>, &'static str> {
|
||||
postcard::to_allocvec(self)
|
||||
.map_err(|_| "manifiesto_firmado :: serializacion fallida")
|
||||
}
|
||||
|
||||
/// Reconstruye un sobre desde su forma binaria. Tolera bytes sobrantes
|
||||
/// tras la estructura — el relleno del registro.
|
||||
pub fn deserializar(bytes: &[u8]) -> Result<ManifiestoFirmado, &'static str> {
|
||||
postcard::take_from_bytes::<ManifiestoFirmado>(bytes)
|
||||
.map(|(mf, _)| mf)
|
||||
.map_err(|_| "manifiesto_firmado :: deserializacion fallida")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "rimay-localize"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "rimay-localize — i18n del escritorio gioser sobre fluent. Catálogos .ftl embebidos por idioma, API global t!(id) con args opcionales, detección de locale del sistema con fallback a es-PE. Los *-core no contienen strings de UI: emiten IDs (MsgId) que las apps Llimphi resuelven aquí."
|
||||
|
||||
[dependencies]
|
||||
fluent-bundle = { workspace = true }
|
||||
unic-langid = { workspace = true }
|
||||
sys-locale = { workspace = true }
|
||||
once_cell = { workspace = true }
|
||||
parking_lot = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -0,0 +1,34 @@
|
||||
# rimay-localize — localización (i18n) declarativa
|
||||
|
||||
Catálogos de cadenas por idioma + una API de lookup minimalista para las apps de
|
||||
gioser. Los catálogos son **datos** (JSON) cargables en tiempo de ejecución; el
|
||||
código pide una clave y un idioma y obtiene la cadena, con interpolación de
|
||||
placeholders `{nombre}`.
|
||||
|
||||
## Qué expone
|
||||
|
||||
- `Lang` — etiqueta de idioma (BCP-47 simplificado).
|
||||
- `Catalog` — mapa clave → cadena para un idioma.
|
||||
- `Localizer` — colección de catálogos + idioma activo + fallback.
|
||||
- Lookup con sustitución de placeholders por argumentos con nombre.
|
||||
|
||||
## Nota de naming
|
||||
|
||||
`rimay` es el dominio de lenguaje/voz; este crate es su utilidad de localización
|
||||
transversal (no es parte del núcleo de `rimay`).
|
||||
|
||||
## Estado (2026-05-31)
|
||||
|
||||
### Hecho
|
||||
- Carga de catálogos JSON por idioma.
|
||||
- Lookup con idioma activo + fallback y tests.
|
||||
- Interpolación de placeholders `{nombre}`.
|
||||
|
||||
### Pendiente
|
||||
- Pluralización compleja (ICU) y reglas por idioma.
|
||||
- Detección del idioma del sistema (hoy lo decide la app).
|
||||
- Herramienta de extracción/validación de claves faltantes.
|
||||
|
||||
## Lugar en el repo
|
||||
|
||||
`shared/rimay-localize` — utilidad i18n transversal para apps gioser.
|
||||
@@ -0,0 +1,37 @@
|
||||
//! `showcase` — imprime todos los IDs del catálogo en los 3 idiomas
|
||||
//! soportados. Smoke test visual + referencia para revisores.
|
||||
//!
|
||||
//! Ejecutar con: `cargo run -p rimay-localize --example showcase`
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use rimay_localize as l10n;
|
||||
|
||||
/// IDs en el orden de aparición en los `.ftl`. Mantener sincronizado a
|
||||
/// mano — el ejemplo es referencia, no test exhaustivo.
|
||||
const IDS: &[&str] = &[
|
||||
"save", "load", "open", "close", "cancel", "confirm", "yes", "no", "delete", "edit", "new",
|
||||
"play", "pause", "resume", "stop", "file", "view", "help", "settings", "exit", "info",
|
||||
"warning", "error", "success",
|
||||
];
|
||||
|
||||
fn main() {
|
||||
let locales = l10n::available_locales();
|
||||
for locale in &locales {
|
||||
l10n::set_locale(locale).unwrap();
|
||||
println!("\n========= {locale} =========");
|
||||
for id in IDS {
|
||||
println!(" {:<10} {}", id, l10n::t(id));
|
||||
}
|
||||
println!(
|
||||
" {:<10} {}",
|
||||
"welcome-user",
|
||||
l10n::t_args("welcome-user", &[("name", Cow::Borrowed("Sergio"))])
|
||||
);
|
||||
println!(
|
||||
" {:<10} {}",
|
||||
"items-count",
|
||||
l10n::t_args("items-count", &[("count", Cow::Borrowed("3"))])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,720 @@
|
||||
# rimay-localize — en-US catalog
|
||||
|
||||
# === generic actions ===
|
||||
save = Save
|
||||
load = Load
|
||||
open = Open
|
||||
close = Close
|
||||
cancel = Cancel
|
||||
confirm = OK
|
||||
yes = Yes
|
||||
no = No
|
||||
delete = Delete
|
||||
edit = Edit
|
||||
new = New
|
||||
|
||||
# === state ===
|
||||
play = Play
|
||||
pause = Pause
|
||||
resume = Resume
|
||||
stop = Stop
|
||||
|
||||
# === menus ===
|
||||
file = File
|
||||
view = View
|
||||
help = Help
|
||||
settings = Settings
|
||||
exit = Exit
|
||||
|
||||
# === common chrome (reusable across all Llimphi apps) ===
|
||||
# Shared menu/action labels. An app only mints its own (`<app>-*`) IDs
|
||||
# for text not covered here.
|
||||
search = Search
|
||||
language = Language
|
||||
undo = Undo
|
||||
redo = Redo
|
||||
cut = Cut
|
||||
copy = Copy
|
||||
paste = Paste
|
||||
select-all = Select All
|
||||
open-dots = Open…
|
||||
save-as = Save As…
|
||||
close-tab = Close Tab
|
||||
find-in-file = Find in File
|
||||
find-in-project = Find in Project
|
||||
symbols = Symbols
|
||||
goto-definition = Go to Definition
|
||||
terminal = Terminal
|
||||
command-palette = Command Palette
|
||||
minimap = Minimap
|
||||
cycle-theme = Cycle Theme
|
||||
editing = Editing
|
||||
about = About
|
||||
refresh = Refresh
|
||||
reconnect = Reconnect
|
||||
|
||||
# === nada (file editor) ===
|
||||
nada-tagline = a sovereign editor on Llimphi
|
||||
|
||||
# === message levels ===
|
||||
info = Info
|
||||
warning = Warning
|
||||
error = Error
|
||||
success = Done
|
||||
|
||||
# === interpolation ===
|
||||
welcome-user = Welcome, { $name }.
|
||||
items-count = { $count } items.
|
||||
|
||||
# === dominium (mean-field simulator) ===
|
||||
dominium-status-running = ● running
|
||||
dominium-status-paused = ‖ paused
|
||||
dominium-status-line = dominium · mean field · epoch { $epoch } · tick { $tick }
|
||||
dominium-btn-pause = ‖ Pause
|
||||
dominium-btn-resume = ▶ Resume
|
||||
dominium-btn-reseed = ↺ Reseed
|
||||
dominium-btn-create-concept = ✦ Create concept
|
||||
dominium-btn-seed-pack = ✚ Seed pack
|
||||
dominium-btn-clear = ✖ Clear
|
||||
dominium-btn-save = 💾 Save
|
||||
dominium-btn-load-saved = 📂 Load saved
|
||||
dominium-btn-load-named = ✓ Load «{ $name }»
|
||||
dominium-header-sim = [ SIM ]
|
||||
dominium-header-conceptos = [ CONCEPTS ]
|
||||
dominium-header-metricas = [ METRICS ]
|
||||
dominium-header-editar = [ EDIT ]
|
||||
dominium-active-count = { $count } active
|
||||
dominium-stat-population = Population
|
||||
dominium-stat-materia = Matter
|
||||
dominium-stat-oro = Gold
|
||||
dominium-stat-energia = Energy
|
||||
dominium-stat-epoca = Epoch
|
||||
dominium-stat-gini-energia = Gini energy
|
||||
dominium-stat-edad-media = Mean age
|
||||
dominium-stat-var-psi-orden = Var ψ order
|
||||
dominium-stat-var-psi-miedo = Var ψ fear
|
||||
dominium-stat-var-psi-curiosidad = Var ψ curiosity
|
||||
dominium-stat-var-psi-corruptib = Var ψ corrupt.
|
||||
dominium-action-mover = → move
|
||||
dominium-action-extraer = → extract
|
||||
dominium-action-sincronizar = → sync
|
||||
dominium-action-intercambiar = → swap
|
||||
dominium-action-replicar = → replicate
|
||||
dominium-action-degradar = → degrade
|
||||
dominium-slider-nombre = name
|
||||
dominium-slider-radius = radius
|
||||
dominium-slider-materia = matter
|
||||
dominium-slider-psique = psyche
|
||||
dominium-slider-poder = power
|
||||
dominium-slider-oro = gold
|
||||
dominium-label-hack = hack:
|
||||
|
||||
# === cosmos (overlay modules) ===
|
||||
cosmos-btn-save-transit = 💾 Save transit as free chart
|
||||
cosmos-btn-save-progressed = 💾 Save progressed as free chart
|
||||
cosmos-btn-save-return = 💾 Save return as free chart
|
||||
cosmos-header = cosmos · { $title } · Asc { $asc }° · MC { $mc }°
|
||||
cosmos-demo-title = Sample chart (Lima)
|
||||
cosmos-demo-subtitle = computed by cosmos-engine (VSOP2013)
|
||||
cosmos-status = { $ms } ms · { $layers } layers · { $overlays } overlays · { $aspects } aspects
|
||||
cosmos-status-error = error: { $err }
|
||||
cosmos-overlay-transit = transit
|
||||
cosmos-overlay-progression = progression
|
||||
cosmos-overlay-solar-arc = solar arc
|
||||
cosmos-overlay-uranian = uranian
|
||||
cosmos-overlay-lots = lots
|
||||
cosmos-overlay-fixed-stars = fixed stars
|
||||
cosmos-overlay-midpoints = midpoints
|
||||
cosmos-harmonic-label = harmonic
|
||||
cosmos-empty = (empty)
|
||||
cosmos-tile-carta = chart
|
||||
cosmos-tile-modulos = modules
|
||||
cosmos-tile-armonico = harmonic
|
||||
cosmos-tile-cuerpos = bodies
|
||||
cosmos-tile-aspectos = aspects
|
||||
cosmos-tile-box-graph = aspectarian
|
||||
cosmos-tile-cualidades = qualities
|
||||
cosmos-elementos = elements
|
||||
cosmos-modalidades = modalities
|
||||
cosmos-polaridad = polarity
|
||||
cosmos-elem-fuego = fire
|
||||
cosmos-elem-tierra = earth
|
||||
cosmos-elem-aire = air
|
||||
cosmos-elem-agua = water
|
||||
cosmos-mod-cardinal = cardinal
|
||||
cosmos-mod-fijo = fixed
|
||||
cosmos-mod-mutable = mutable
|
||||
cosmos-pol-yang = yang
|
||||
cosmos-pol-yin = yin
|
||||
cosmos-tile-astrocarto = astrocartography
|
||||
cosmos-astrocarto-leyenda = MC solid · IC dashed · Asc/Desc curves · • natal place
|
||||
cosmos-tile-cartas = saved charts
|
||||
cosmos-cartas-duplicar = + duplicate current
|
||||
cosmos-cartas-vacio = (empty — duplicate current or drop JSONs in the dir)
|
||||
cosmos-tile-corpus = corpus
|
||||
cosmos-tile-lotes = lots
|
||||
cosmos-tile-estrellas-fijas = fixed stars
|
||||
cosmos-tile-puntos-medios = midpoints
|
||||
cosmos-corpus-header = { $pasajes } passages · { $huecos } gaps · { $total } combos
|
||||
cosmos-corpus-vacio = (no passages — write your corpus in cosmos-corpus/ejemplo.ron)
|
||||
cosmos-tile-uraniano = uranian dial 90°
|
||||
cosmos-tile-cross-transit = cross · transit
|
||||
cosmos-tile-cross-progression = cross · progression
|
||||
cosmos-tile-cross-solar-arc = cross · solar arc
|
||||
|
||||
# === wawa-explorer (Wawa image browser) ===
|
||||
wawa-marker-via-aoe = · via AoE
|
||||
wawa-marker-searching = · searching…
|
||||
wawa-marker-fetch-failed = · fetch failed
|
||||
wawa-marker-not-in-image = · (not in image)
|
||||
wawa-iface-ok = · AoE iface: { $name }
|
||||
wawa-iface-err = · AoE: no interface
|
||||
wawa-header-error = wawa-explorer · error: { $err }
|
||||
wawa-header = wawa-explorer · { $source } · { $bytes } bytes · v{ $version } · cursor sector { $cursor } · { $objects } objects{ $iface }
|
||||
wawa-detail-empty = (select an object from the tree)
|
||||
wawa-detail-title = object { $hash } · { $bytes } bytes · { $children } children{ $origen }
|
||||
wawa-detail-title-missing = object { $hash } · not present locally
|
||||
wawa-detail-payload-header = payload (first 256 bytes):
|
||||
wawa-detail-children-header = children:
|
||||
wawa-detail-child-missing = (not in image)
|
||||
wawa-detail-searching-aoe-1 = searching the local network (AoE)…
|
||||
wawa-detail-searching-aoe-2 = broadcast SolicitarObjeto, awaiting ProveedorObjeto with verified hash.
|
||||
wawa-detail-fetch-error-1 = last AoE attempt failed:
|
||||
wawa-detail-fetch-error-2 = you can retry with the button below.
|
||||
wawa-detail-needs-fetch-1 = this object is referenced by a parent but does not live in the local image.
|
||||
wawa-detail-needs-fetch-2 = you can request it from Wawa peers on the local network (AoE, iface `{ $iface }`).
|
||||
wawa-detail-aoe-disabled-1 = this object is referenced by a parent but does not live in the local image.
|
||||
wawa-detail-aoe-disabled-2 = AoE disabled: { $why }
|
||||
wawa-detail-aoe-disabled-3 = pass `<iface>` as the second CLI argument or run with CAP_NET_RAW (`sudo setcap cap_net_raw=eip <binary>`).
|
||||
wawa-btn-fetch = fetch from peers
|
||||
wawa-btn-retry-fetch = retry fetch from peers
|
||||
# main menu bar
|
||||
wawa-menu-file = File
|
||||
wawa-menu-reload = Reload image
|
||||
wawa-menu-quit = Quit
|
||||
wawa-menu-view = View
|
||||
wawa-menu-fetch = Fetch node via AoE
|
||||
wawa-menu-theme = Toggle theme
|
||||
wawa-menu-help = Help
|
||||
wawa-menu-about = About
|
||||
# context menu on the selected node
|
||||
wawa-ctx-select = Select
|
||||
wawa-ctx-expand = Expand
|
||||
wawa-ctx-collapse = Collapse
|
||||
wawa-ctx-fetch = Fetch via AoE
|
||||
|
||||
# === minga-explorer (repo browser) ===
|
||||
minga-header-loaded = Repo: { $path } · reload { $ms } ms
|
||||
minga-header-searching = Searching repo at { $path }…
|
||||
minga-error-read = could not read repo { $path }: { $err }
|
||||
minga-card-nodes-title = AST Nodes
|
||||
minga-card-nodes-desc = code fragments parsed
|
||||
minga-card-attestations-title = Attestations
|
||||
minga-card-attestations-desc = Ed25519 signatures over nodes
|
||||
minga-card-mst-title = MST Keys
|
||||
minga-card-mst-desc = Merkle Search Tree entries
|
||||
minga-empty = Waiting for first refresh…
|
||||
minga-menu-file = File
|
||||
minga-menu-view = View
|
||||
minga-menu-help = Help
|
||||
minga-menu-refresh = Refresh
|
||||
minga-menu-quit = Quit
|
||||
minga-menu-theme = Toggle theme
|
||||
minga-menu-about = About
|
||||
minga-menu-context-title = Repo
|
||||
|
||||
# === nakui-explorer (event log) ===
|
||||
nakui-explorer-header = Log: { $path } · { $entries } entries ({ $seeds } seeds, { $morphisms } morphisms) · reload { $ms } ms
|
||||
nakui-explorer-breakdown = breakdown: { $parts }
|
||||
|
||||
# === supay (doom) ===
|
||||
supay-mode-real = REAL ENGINE
|
||||
supay-mode-stub = STUB
|
||||
supay-view-fb = view=FB (F3→3D)
|
||||
supay-view-3d = view=3D (F3→FB)
|
||||
supay-header = { $title } · tick { $tick } · { $mode } · { $view } · { $scene }
|
||||
supay-stub-title = supay-doom-llimphi is running in STUB mode
|
||||
supay-stub-step-1 = Clone doomgeneric
|
||||
supay-stub-step-1-cmd = cd 02_ruway/supay/supay-core/vendor && git clone https://github.com/ozkl/doomgeneric.git
|
||||
supay-stub-step-2 = Drop the shareware WAD in the cwd
|
||||
supay-stub-step-2-cmd = curl -O https://distro.ibiblio.org/slitaz/sources/packages/d/doom1.wad
|
||||
supay-stub-step-3 = Run it again
|
||||
supay-stub-step-3-cmd = cargo run -p supay-doom-llimphi --release
|
||||
supay-stub-footer = doomgeneric (C) ticks at 35 Hz; the 320×200 ARGB framebuffer paints in aspect-fit.
|
||||
supay-controls-hint = WASD · Ctrl fire · Space use · Tab map · F3 view · F4 cross · F5 vig · F6 HUD · F7 shadows · F8 muzzle · F9 occl · F10 mobj-lit · F11 rim · F12 quit
|
||||
supay-stub-controls-hint = F3 toggles FB/3D · F12 closes the window
|
||||
|
||||
# === shuma-shell ===
|
||||
shuma-label-launcher = Launcher
|
||||
shuma-label-command = Command
|
||||
shuma-label-shell = Shell
|
||||
shuma-label-matilda = Matilda
|
||||
shuma-label-canvas = Canvas
|
||||
shuma-label-monitors = Monitors
|
||||
shuma-empty-main-incompat = Main module not compatible
|
||||
shuma-empty-no-tabs = No tabs configured.
|
||||
shuma-empty-no-tabs-compat = This module cannot be a tab.
|
||||
shuma-empty-no-data-linux = no data (not Linux?)
|
||||
shuma-empty-no-data = no data
|
||||
shuma-stat-samples = samples: { $have } / { $total }
|
||||
|
||||
# === nahual (viewers) ===
|
||||
nahual-image-unsupported = unsupported format (only PNG/JPEG in this build)
|
||||
|
||||
# === greeter (mirada login) ===
|
||||
greeter-subtitle = sign in
|
||||
greeter-label-user = username
|
||||
greeter-label-password = password
|
||||
greeter-placeholder-user = enter your username
|
||||
greeter-status-authenticating = verifying…
|
||||
greeter-error-empty-user = enter a username
|
||||
|
||||
# === nakui (ERP shell) ===
|
||||
nakui-header = Nakui · { $count } module(s)
|
||||
nakui-sidebar-modules = Modules ({ $count })
|
||||
nakui-sidebar-menu = Menu
|
||||
nakui-empty-no-modules = No modules loaded
|
||||
nakui-empty-pick-module = Pick a module in the sidebar
|
||||
nakui-empty-pick-menu = Pick a menu in the sidebar
|
||||
nakui-pending-edit = edit pending: requires Llimphi meta-form
|
||||
nakui-pending-render-detail = render pending: requires Llimphi meta-form
|
||||
nakui-pending-render-dashboard = render pending: requires Llimphi dashboard
|
||||
|
||||
# === pluma (DAG editor) ===
|
||||
pluma-tone-valid = coherent
|
||||
pluma-tone-pending = pending
|
||||
pluma-tone-conflict = conflict
|
||||
|
||||
# === gioser-edit (code editor) ===
|
||||
edit-status-find = find · Ctrl+G next · Esc closes
|
||||
edit-status-goto-def-waiting = goto-def · waiting for LSP…
|
||||
edit-status-references-waiting = references · waiting for LSP…
|
||||
edit-status-rename-input = rename · Enter applies · Esc cancels
|
||||
edit-status-rename-waiting = rename → «{ $name }» · waiting for LSP…
|
||||
edit-status-rename-error = rename · error in { $path }: { $err }
|
||||
edit-status-rename-done = rename · { $files } files · { $bytes } bytes
|
||||
edit-status-formatting-waiting = formatting · waiting for LSP…
|
||||
edit-status-formatting-done = formatting · applied
|
||||
edit-status-goto-def-at = goto-def · { $path }:{ $line }
|
||||
edit-status-goto-def-error = goto-def · error opening { $path }: { $err }
|
||||
edit-status-saved = saved · { $path }
|
||||
edit-status-save-error = save error: { $err }
|
||||
edit-header-hint = Ctrl+Shift+P palette · Ctrl+P files · Ctrl+Shift+F search
|
||||
edit-status-position = Ln { $line }, Col { $col } · { $lang }
|
||||
|
||||
# === chasqui-explorer (monads) ===
|
||||
chasqui-header = Engine '{ $engine }' · { $count } monad(s) · socket: { $socket } ({ $src }){ $watching }
|
||||
chasqui-header-watching = · watching: { $name }
|
||||
chasqui-header-searching = Searching chasqui daemon via brahman-broker…
|
||||
chasqui-field-id = id: { $id }
|
||||
chasqui-field-watching = watching: { $name }
|
||||
chasqui-field-keywords = keywords: { $keywords }
|
||||
chasqui-field-path = path: { $path }
|
||||
chasqui-field-model = model: { $name }
|
||||
|
||||
# === wawa-panel (wawa OS control panel) ===
|
||||
wawa-panel-title = wawa · control panel
|
||||
wawa-panel-cat-appearance = Appearance
|
||||
wawa-panel-cat-language = Language
|
||||
wawa-panel-cat-apps = Applications
|
||||
wawa-panel-cat-monitor = Monitor
|
||||
wawa-panel-cat-modules = Modules
|
||||
wawa-panel-cat-about = About
|
||||
wawa-panel-section-appearance-hint = Theme variant and accent.
|
||||
wawa-panel-section-language-hint = System language and clock format.
|
||||
wawa-panel-section-apps-hint = Launch wawa native apps.
|
||||
wawa-panel-section-monitor-hint = Live system state.
|
||||
wawa-panel-section-modules-hint = Enable or disable OS modules.
|
||||
wawa-panel-section-about-hint = Operating system information.
|
||||
wawa-panel-label-variant = Variant
|
||||
wawa-panel-label-accent = Accent
|
||||
wawa-panel-label-language = Language
|
||||
wawa-panel-label-clock = Clock
|
||||
wawa-panel-variant-dark = Dark
|
||||
wawa-panel-variant-light = Light
|
||||
wawa-panel-variant-aurora = Aurora
|
||||
wawa-panel-variant-sunset = Sunset
|
||||
wawa-panel-clock-24h = 24 h
|
||||
wawa-panel-clock-12h = 12 h
|
||||
wawa-panel-stat-time = Time
|
||||
wawa-panel-stat-uptime = Uptime
|
||||
wawa-panel-stat-mem = Memory
|
||||
wawa-panel-stat-load = Load
|
||||
wawa-panel-stat-host = Host
|
||||
wawa-panel-stat-kernel = Kernel
|
||||
wawa-panel-action-launch = Launch
|
||||
wawa-panel-action-save = Save config
|
||||
wawa-panel-action-reset = Reset
|
||||
wawa-panel-saved = Configuration saved to { $path }
|
||||
wawa-panel-reset = Configuration reset to defaults
|
||||
wawa-panel-menu-file = File
|
||||
wawa-panel-menu-view = View
|
||||
wawa-panel-menu-help = Help
|
||||
wawa-panel-menu-quit = Quit
|
||||
wawa-panel-status-hint = ↑↓ navigate · Enter activate · Ctrl+S save · Esc exit
|
||||
wawa-panel-about-name = System
|
||||
wawa-panel-about-version = Version
|
||||
wawa-panel-about-kernel = Kernel
|
||||
wawa-panel-about-toolkit = Toolkit
|
||||
wawa-panel-about-blurb = wawa is the operating system of the gioser suite. arje kernel and llimphi apps over a minimal userland.
|
||||
wawa-panel-mod-mirada = mirada · wayland compositor
|
||||
wawa-panel-mod-shuma = shuma · packaging and releases
|
||||
wawa-panel-mod-chasqui = chasqui · mail and messaging
|
||||
wawa-panel-mod-akasha = akasha · update channel
|
||||
wawa-panel-mod-minga = minga · p2p storage
|
||||
wawa-panel-mod-agora = agora · public square
|
||||
wawa-panel-mod-on = on
|
||||
wawa-panel-mod-off = off
|
||||
|
||||
# === mirada-asistente ===
|
||||
# Llimphi app that translates natural language into mirada-ctl commands
|
||||
# by consulting an LLM. The AI proposes; the human confirms before executing.
|
||||
asistente-title = carmen · assistant
|
||||
asistente-sub = tell me what you want to do; the assistant proposes, you confirm.
|
||||
asistente-placeholder = what do you want to do? (Enter to ask, Esc to clear)
|
||||
asistente-banner-no-llm = LLM unavailable: { $motivo }
|
||||
asistente-status-pensando = thinking…
|
||||
asistente-boton-ejecutar = Run
|
||||
asistente-boton-descartar = Discard
|
||||
asistente-ejecutado-ok = ✓ { $accion } executed
|
||||
asistente-ejecutado-fallo = ✗ { $accion } failed
|
||||
asistente-error-transporte = transport: { $motivo }
|
||||
asistente-error-sin-llm = LLM not initialized
|
||||
asistente-error-sin-json = response without JSON: { $crudo }
|
||||
asistente-error-accion-vacia = proposal without action: { $crudo }
|
||||
asistente-error-json-invalido = unrecognized JSON: { $crudo }
|
||||
asistente-error-spawn = spawn failed: { $err } (is mirada-ctl in PATH?)
|
||||
asistente-cero-salida = (no output)
|
||||
asistente-codigo-salida = exit code { $codigo }
|
||||
asistente-error-accion-desconocida = LLM proposed an unknown action: { $accion }
|
||||
|
||||
|
||||
# === ayni-llimphi ===
|
||||
ayni-menu-admitir = Admit selected
|
||||
ayni-menu-atestar = Attest selected
|
||||
ayni-menu-expulsar = Expel selected
|
||||
ayni-menu-enviar-msg = Send message
|
||||
ayni-menu-adjuntar = Attach file…
|
||||
ayni-menu-acuse = Read receipt
|
||||
ayni-menu-cifrado = E2EE Encryption
|
||||
ayni-menu-recibos = Read receipts
|
||||
ayni-menu-comandos-barra = Slash commands
|
||||
ayni-label-gente-miembros = PEOPLE — members
|
||||
ayni-label-otros-vistos = others seen
|
||||
ayni-label-acciones = actions
|
||||
ayni-label-elige-alguien = pick someone above
|
||||
ayni-btn-admitir = admit
|
||||
ayni-btn-atestar = attest
|
||||
ayni-btn-expulsar = expel
|
||||
ayni-btn-acuse = receipt
|
||||
ayni-label-confianza = trust (hops)
|
||||
ayni-label-sin-atestaciones = — no attestations —
|
||||
ayni-label-sin-mensajes = — no messages. Type below (or /help for commands). —
|
||||
ayni-compose-placeholder = type a message, or /adjuntar <path>, /atestar <hex> …
|
||||
ayni-btn-enviar = send
|
||||
|
||||
# === chaka-app-llimphi ===
|
||||
chaka-menu-run = Run
|
||||
chaka-menu-run-pipeline = Run pipeline
|
||||
chaka-tab-output = Output
|
||||
chaka-tab-rust = Generated Rust
|
||||
chaka-tab-diag = Diagnostics
|
||||
chaka-btn-run = Run
|
||||
chaka-corpus-empty = empty corpus
|
||||
chaka-corpus-header = CORPUS
|
||||
chaka-editor-placeholder = select a program from the corpus
|
||||
chaka-no-file = no file
|
||||
chaka-banner-open-corpus = open a program from the corpus on the left
|
||||
chaka-banner-step-limit = shadow ⚠ step limit reached (infinite loop?)
|
||||
chaka-banner-pipeline-error = the pipeline failed — see the «Diag» tab for details
|
||||
chaka-status-no-open-file = no open file to save
|
||||
chaka-about-text = chaka · COBOL → Rust transpiler · pipeline lex→parse→ir→codegen→shadow
|
||||
|
||||
# === chasqui-explorer-llimphi ===
|
||||
chasqui-explorer-ctx-detail = View detail
|
||||
chasqui-explorer-monad-label = Monad
|
||||
chasqui-explorer-monad-stats = { $count } files · ent { $entropy } · { $lens }
|
||||
|
||||
# === media-app ===
|
||||
media-settings-tab-audio = Audio
|
||||
media-settings-tab-video = Video
|
||||
media-settings-tab-playback = Playback
|
||||
media-settings-tab-bars = Bars
|
||||
media-settings-tab-controls = Controls
|
||||
media-audio-volume = Volume
|
||||
media-audio-eq = Equalizer
|
||||
media-audio-normalization = Normalization
|
||||
media-audio-lufs-target = LUFS Target
|
||||
media-audio-downmix = Stereo downmix
|
||||
media-video-color = Color
|
||||
media-video-enable = Enable
|
||||
media-video-brightness = Brightness
|
||||
media-video-contrast = Contrast
|
||||
media-video-gamma = Gamma
|
||||
media-video-saturation = Saturation
|
||||
media-video-hue = Hue
|
||||
media-video-orientation = Orientation
|
||||
media-video-rotation = Rotation
|
||||
media-video-rotate-cw = rotate 90°
|
||||
media-video-flip-h = Flip H
|
||||
media-video-flip-v = Flip V
|
||||
media-action-reset = reset
|
||||
media-action-cycle = cycle
|
||||
media-playback-playlist = Playlist
|
||||
media-playback-resume = Resume on open
|
||||
media-playback-repeat = Repeat
|
||||
media-playback-shuffle = Shuffle
|
||||
media-playback-subtitles = Subtitles
|
||||
media-playback-autoload-sidecar = Auto-load sidecar
|
||||
media-playback-sub-delay = Delay (ms)
|
||||
media-playback-font-size = Font size
|
||||
media-playback-behavior = Behavior
|
||||
media-playback-crossfade = Crossfade (s)
|
||||
media-controls-header = Controls (keyboard)
|
||||
media-controls-hint = Edit controles.ron and press F5 to reassign keys. The visual shortcut editor is coming later.
|
||||
media-bars-header = Control bars — click an item to remove it
|
||||
media-bars-bar-label = Bar
|
||||
media-bars-remove-bar = − remove bar
|
||||
media-bars-add-bar = + new bar
|
||||
media-bars-add-items-to = Add items to:
|
||||
media-settings-footer = Saved to config.ron · Esc closes · in Bars: click an item to remove, ‹ › reorder
|
||||
media-playlist-header = Playlist — click a track to jump
|
||||
media-playlist-empty = No playlist.
|
||||
media-win-config-title = Settings — media
|
||||
media-win-playlist-title = Playlist — media
|
||||
media-help-title = media · shortcuts
|
||||
media-help-group-playback = Playback
|
||||
media-help-toggle = Show/hide this help
|
||||
media-help-close = Close help
|
||||
media-help-reload = Hot-reload controles.ron
|
||||
media-menu-capture-frame = Capture frame
|
||||
media-menu-record = Record / stop
|
||||
media-menu-reload-controls = Reload controls
|
||||
media-menu-playback = Playback
|
||||
media-menu-play-pause = Play / pause
|
||||
media-menu-seek-back = Seek backward
|
||||
media-menu-seek-fwd = Seek forward
|
||||
media-menu-prev-track = Previous track
|
||||
media-menu-next-track = Next track
|
||||
media-menu-volume-up = Volume up
|
||||
media-menu-volume-down = Volume down
|
||||
media-menu-playlist = Playlist
|
||||
media-menu-visualizers = Audio visualizers
|
||||
media-menu-shortcuts-help = Shortcut help
|
||||
media-ctx-stop-record = Stop recording
|
||||
media-ctx-record-audio = Record audio
|
||||
|
||||
# === mirada-app-llimphi ===
|
||||
mirada-menu-open-window = Open window
|
||||
mirada-menu-open-output = Open monitor
|
||||
mirada-menu-close-focused = Close focused
|
||||
mirada-menu-window = Window
|
||||
mirada-win-promote = Promote to master
|
||||
mirada-win-float = Float / anchor
|
||||
mirada-win-fullscreen = Full screen
|
||||
mirada-win-scratchpad = Send to scratchpad
|
||||
mirada-win-label-fallback = window
|
||||
mirada-layout-cycle = Cycle layout
|
||||
mirada-layout-master-stack = Master + stack
|
||||
mirada-layout-monocle = Monocle
|
||||
mirada-layout-grid = Grid
|
||||
mirada-layout-columns = Columns
|
||||
mirada-layout-rows = Rows
|
||||
mirada-layout-centered = Centered master
|
||||
mirada-layout-spiral = Spiral
|
||||
mirada-layout-shrink = Shrink master
|
||||
mirada-layout-grow = Grow master
|
||||
mirada-output-next = Next monitor
|
||||
mirada-status-body-connected = Body connected
|
||||
mirada-status-simulation = simulation — no Body
|
||||
mirada-status-keymap-reloaded = keymap reloaded
|
||||
mirada-status-keymap-invalid = invalid keymap
|
||||
mirada-label-layout = layout
|
||||
mirada-label-focus = focus
|
||||
mirada-label-output = output
|
||||
mirada-label-workspace = workspace
|
||||
mirada-canvas-empty-hint = empty workspace — press n to open a window
|
||||
mirada-win-kind-fullscreen = · full screen ·
|
||||
mirada-win-kind-floating = · floating window ·
|
||||
mirada-win-kind-surface = · body surface ·
|
||||
|
||||
# === mirada-greeter ===
|
||||
mirada-greeter-menu-session = Session
|
||||
mirada-greeter-session-submit = Log in
|
||||
mirada-greeter-session-goto-user = Go to username
|
||||
mirada-greeter-session-goto-pass = Go to password
|
||||
mirada-greeter-label-desktop = Desktop
|
||||
mirada-greeter-btn-submit = Log in
|
||||
mirada-greeter-btn-submitting = Logging in…
|
||||
mirada-greeter-hint-nav = ↑/↓: desktop · Enter: log in
|
||||
mirada-greeter-hint-console = Ctrl+Alt+F1…F12: console · Ctrl+Alt+⌫: exit
|
||||
|
||||
# === nakui-explorer-llimphi ===
|
||||
nakui-explorer-menu-refresh-log = Refresh log
|
||||
nakui-explorer-ctx-view-detail = View detail
|
||||
nakui-explorer-ctx-refresh-log = Refresh log
|
||||
nakui-explorer-ctx-entry-fallback = Entry
|
||||
|
||||
# === nakui-sheet-llimphi ===
|
||||
nakui-sheet-ctx-clear = Clear
|
||||
nakui-sheet-fmt-number = Format: Number
|
||||
nakui-sheet-fmt-currency = Format: Currency $
|
||||
nakui-sheet-fmt-percent = Format: Percentage
|
||||
nakui-sheet-fmt-general = Format: General
|
||||
nakui-sheet-freeze-here = Freeze Panes Here
|
||||
nakui-sheet-unfreeze = Unfreeze Panes
|
||||
nakui-sheet-pivot = Pivot Table…
|
||||
nakui-sheet-menu-cell-cut = Cut Cell
|
||||
nakui-sheet-menu-cell-copy = Copy Cell
|
||||
nakui-sheet-menu-cell-paste = Paste Cell
|
||||
nakui-sheet-menu-cell-clear = Clear Cell
|
||||
nakui-sheet-menu-bar-cut = Cut Text
|
||||
nakui-sheet-menu-bar-copy = Copy Text
|
||||
nakui-sheet-menu-bar-paste = Paste Text
|
||||
nakui-sheet-menu-bar-select-all = Select All (text)
|
||||
nakui-sheet-menu-import-csv = Import CSV
|
||||
nakui-sheet-menu-export-csv = Export CSV
|
||||
nakui-sheet-menu-about = About Nakui Sheet
|
||||
nakui-sheet-formula-placeholder = enter formula or value
|
||||
nakui-sheet-pivot-title = Pivot Table
|
||||
nakui-sheet-pivot-close = ✕ Esc
|
||||
nakui-sheet-pivot-group-by = Group by «
|
||||
nakui-sheet-pivot-over = over
|
||||
nakui-sheet-pivot-with-header = w/header
|
||||
nakui-sheet-pivot-no-header = no header
|
||||
nakui-sheet-pivot-more-groups = groups
|
||||
nakui-sheet-pivot-total = TOTAL
|
||||
nakui-sheet-pivot-groups = groups
|
||||
nakui-sheet-pivot-rows = rows
|
||||
nakui-sheet-pivot-hint = A function · G group · V value · H header · Esc close
|
||||
|
||||
# === paloma-llimphi ===
|
||||
paloma-status-init = paloma · not synced
|
||||
paloma-status-search-semantic = semantic search (rimay): pending — using exact
|
||||
paloma-status-view-rich = rich HTML via puriy: pending (plain text for now)
|
||||
paloma-status-no-recipient = cannot send: missing a valid recipient
|
||||
paloma-status-sent = sent
|
||||
paloma-status-sent-signed = sent · signed (Ed25519)
|
||||
paloma-placeholder-search = Search… ( / )
|
||||
paloma-btn-compose = ✎ Compose
|
||||
paloma-nav-calendar = Calendar
|
||||
paloma-nav-contacts = Contacts
|
||||
paloma-nav-soon = soon
|
||||
paloma-empty-threads = Empty inbox
|
||||
paloma-empty-search = no matches
|
||||
paloma-search-exact = Exact
|
||||
paloma-search-semantic = Semantic
|
||||
paloma-no-subject = (no subject)
|
||||
paloma-placeholder-read = Select a thread to read it
|
||||
paloma-btn-reply = Reply
|
||||
paloma-btn-forward = Forward
|
||||
paloma-btn-star = Star
|
||||
paloma-btn-starred = Starred
|
||||
paloma-btn-mark-unread = Mark as unread
|
||||
paloma-btn-mark-read = Mark as read
|
||||
paloma-btn-view-rich = View rich HTML
|
||||
paloma-msg-to-label = to
|
||||
paloma-sig-verified = signed
|
||||
paloma-sig-invalid = invalid signature
|
||||
paloma-compose-new = New message
|
||||
paloma-compose-reply-title = Reply
|
||||
paloma-compose-placeholder-to = To: name <email@domain>
|
||||
paloma-compose-placeholder-cc = Cc: (optional)
|
||||
paloma-compose-placeholder-subject = Subject
|
||||
paloma-compose-placeholder-body = Write your message…
|
||||
paloma-compose-sign = Sign (Ed25519)
|
||||
paloma-compose-send = Send
|
||||
|
||||
# === pluma-notebook-llimphi ===
|
||||
pluma-notebook-fit-all = Fit All
|
||||
pluma-notebook-center = Center
|
||||
pluma-notebook-zoom-reset = Zoom 100%
|
||||
|
||||
# === raymi-llimphi ===
|
||||
raymi-tab-calendar = Calendar
|
||||
raymi-tab-contacts = Contacts
|
||||
raymi-view-month = Month
|
||||
raymi-view-week = Week
|
||||
raymi-view-day = Day
|
||||
raymi-btn-new-event = + Event
|
||||
raymi-btn-today = Today
|
||||
raymi-btn-new-contact = + Contact
|
||||
raymi-no-events = no events
|
||||
raymi-all-day = all day
|
||||
raymi-no-contacts = no contacts
|
||||
raymi-search-contact-placeholder = 🔍 Search contact…
|
||||
raymi-select-contact-hint = Select a contact
|
||||
raymi-title-edit-event = Edit event
|
||||
raymi-title-new-event = New event
|
||||
raymi-title-edit-contact = Edit contact
|
||||
raymi-title-new-contact = New contact
|
||||
raymi-change-cycle = change
|
||||
raymi-field-summary = Subject
|
||||
raymi-field-all-day = All day
|
||||
raymi-field-apply-to = Apply to
|
||||
raymi-field-calendar = Calendar
|
||||
raymi-field-date = Date
|
||||
raymi-field-start = Start
|
||||
raymi-field-end = End
|
||||
raymi-field-location = Location
|
||||
raymi-ph-location = Location (optional)
|
||||
raymi-field-description = Description
|
||||
raymi-ph-description = Notes (optional)
|
||||
raymi-field-attendees = Attendees
|
||||
raymi-ph-invitee = Name <email> · Enter
|
||||
raymi-field-repeat = Repeat
|
||||
raymi-field-every = Every
|
||||
raymi-field-days = Days
|
||||
raymi-field-ends = Ends
|
||||
raymi-field-name = Name
|
||||
raymi-field-emails = Emails
|
||||
raymi-field-phones = Phones
|
||||
raymi-field-org = Organization
|
||||
raymi-field-note = Note
|
||||
raymi-ph-full-name = Full name
|
||||
raymi-ph-emails = email@domain, other@…
|
||||
raymi-ph-phones = +1 555…, …
|
||||
raymi-ph-org = Company (optional)
|
||||
raymi-ph-note = Note (optional)
|
||||
raymi-scope-series = All events in series
|
||||
raymi-scope-this-only = This event only
|
||||
raymi-scope-this-and-future = This and following events
|
||||
raymi-repeat-none = Does not repeat
|
||||
raymi-repeat-daily = Daily
|
||||
raymi-repeat-weekly = Weekly
|
||||
raymi-repeat-monthly = Monthly
|
||||
raymi-repeat-yearly = Yearly
|
||||
raymi-unit-days = day(s)
|
||||
raymi-unit-weeks = week(s)
|
||||
raymi-unit-months = month(s)
|
||||
raymi-unit-years = year(s)
|
||||
raymi-end-never = Never ends
|
||||
raymi-end-count = After N times
|
||||
raymi-end-until = Until date
|
||||
raymi-status-no-calendars = no calendars available to create an event
|
||||
raymi-status-no-books = no address books available to create a contact
|
||||
raymi-status-invalid-datetime = invalid date or time (use YYYY-MM-DD and HH:MM)
|
||||
raymi-status-contact-needs-name = contact requires a name
|
||||
|
||||
# === shuma-shell-llimphi ===
|
||||
shuma-shell-clear-input = Clear input
|
||||
shuma-shell-clear-screen = Clear screen
|
||||
shuma-shell-cancel-cmd = Cancel command
|
||||
shuma-shell-about = About shuma
|
||||
|
||||
# === supay-app-llimphi ===
|
||||
supay-hud-health = HEALTH
|
||||
supay-hud-ammo = AMMO
|
||||
supay-hud-target = TARGET
|
||||
supay-action-fire = Fire
|
||||
supay-action-reset = Restart game
|
||||
supay-menu-play = Play
|
||||
supay-status-game-over = game over
|
||||
supay-status-victory = victory
|
||||
supay-status-dead = DEAD
|
||||
supay-hint-space-restart = SPACE to restart
|
||||
|
||||
# === wawa-panel-llimphi ===
|
||||
wawa-panel-status-config-updated = ↻ config updated from bus
|
||||
wawa-panel-ctx-refresh-monitor = Refresh monitor
|
||||
wawa-panel-autosave-ok = ↻ applied
|
||||
@@ -0,0 +1,723 @@
|
||||
# rimay-localize — catálogo es-PE
|
||||
# Convención: IDs en kebab-case, ASCII, en inglés (estables); traducción
|
||||
# en este archivo. Comentarios (#) describen contexto cuando el ID no
|
||||
# basta.
|
||||
|
||||
# === acciones genéricas ===
|
||||
save = Guardar
|
||||
load = Cargar
|
||||
open = Abrir
|
||||
close = Cerrar
|
||||
cancel = Cancelar
|
||||
confirm = Aceptar
|
||||
yes = Sí
|
||||
no = No
|
||||
delete = Eliminar
|
||||
edit = Editar
|
||||
new = Nuevo
|
||||
|
||||
# === estado ===
|
||||
play = Reproducir
|
||||
pause = Pausar
|
||||
resume = Reanudar
|
||||
stop = Detener
|
||||
|
||||
# === menús ===
|
||||
file = Archivo
|
||||
view = Vista
|
||||
help = Ayuda
|
||||
settings = Configuración
|
||||
exit = Salir
|
||||
|
||||
# === chrome común (reutilizable por todas las apps Llimphi) ===
|
||||
# Etiquetas de menú/acción compartidas. Una app sólo crea IDs propios
|
||||
# (`<app>-*`) para texto que no aparezca acá.
|
||||
search = Buscar
|
||||
language = Idioma
|
||||
undo = Deshacer
|
||||
redo = Rehacer
|
||||
cut = Cortar
|
||||
copy = Copiar
|
||||
paste = Pegar
|
||||
select-all = Seleccionar todo
|
||||
open-dots = Abrir…
|
||||
save-as = Guardar como…
|
||||
close-tab = Cerrar pestaña
|
||||
find-in-file = Buscar en archivo
|
||||
find-in-project = Buscar en proyecto
|
||||
symbols = Símbolos
|
||||
goto-definition = Ir a definición
|
||||
terminal = Terminal
|
||||
command-palette = Paleta de comandos
|
||||
minimap = Minimapa
|
||||
cycle-theme = Cambiar tema
|
||||
editing = Edición
|
||||
about = Acerca de
|
||||
refresh = Refrescar
|
||||
reconnect = Reconectar
|
||||
|
||||
# === nada (editor de archivos) ===
|
||||
nada-tagline = editor soberano sobre Llimphi
|
||||
|
||||
# === niveles de mensaje ===
|
||||
info = Información
|
||||
warning = Advertencia
|
||||
error = Error
|
||||
success = Listo
|
||||
|
||||
# === interpolación ===
|
||||
welcome-user = Bienvenido, { $name }.
|
||||
items-count = { $count } elementos.
|
||||
|
||||
# === dominium (simulador de campo medio) ===
|
||||
dominium-status-running = ● corriendo
|
||||
dominium-status-paused = ‖ en pausa
|
||||
dominium-status-line = dominium · campo medio · época { $epoch } · tick { $tick }
|
||||
dominium-btn-pause = ‖ Pausar
|
||||
dominium-btn-resume = ▶ Reanudar
|
||||
dominium-btn-reseed = ↺ Re-sembrar
|
||||
dominium-btn-create-concept = ✦ Crear concepto
|
||||
dominium-btn-seed-pack = ✚ Sembrar pack
|
||||
dominium-btn-clear = ✖ Limpiar
|
||||
dominium-btn-save = 💾 Guardar
|
||||
dominium-btn-load-saved = 📂 Cargar guardado
|
||||
dominium-btn-load-named = ✓ Cargar «{ $name }»
|
||||
dominium-header-sim = [ SIM ]
|
||||
dominium-header-conceptos = [ CONCEPTOS ]
|
||||
dominium-header-metricas = [ MÉTRICAS ]
|
||||
dominium-header-editar = [ EDITAR ]
|
||||
dominium-active-count = { $count } activos
|
||||
dominium-stat-population = Población
|
||||
dominium-stat-materia = Materia
|
||||
dominium-stat-oro = Oro
|
||||
dominium-stat-energia = Energía
|
||||
dominium-stat-epoca = Época
|
||||
dominium-stat-gini-energia = Gini energía
|
||||
dominium-stat-edad-media = Edad media
|
||||
dominium-stat-var-psi-orden = Var ψ orden
|
||||
dominium-stat-var-psi-miedo = Var ψ miedo
|
||||
dominium-stat-var-psi-curiosidad = Var ψ curiosidad
|
||||
dominium-stat-var-psi-corruptib = Var ψ corruptib.
|
||||
dominium-action-mover = → mover
|
||||
dominium-action-extraer = → extraer
|
||||
dominium-action-sincronizar = → sincronizar
|
||||
dominium-action-intercambiar = → intercambiar
|
||||
dominium-action-replicar = → replicar
|
||||
dominium-action-degradar = → degradar
|
||||
dominium-slider-nombre = nombre
|
||||
dominium-slider-radius = radius
|
||||
dominium-slider-materia = materia
|
||||
dominium-slider-psique = psique
|
||||
dominium-slider-poder = poder
|
||||
dominium-slider-oro = oro
|
||||
dominium-label-hack = hack:
|
||||
|
||||
# === cosmos (módulos overlay) ===
|
||||
cosmos-btn-save-transit = 💾 Guardar tránsito como carta libre
|
||||
cosmos-btn-save-progressed = 💾 Guardar progresada como carta libre
|
||||
cosmos-btn-save-return = 💾 Guardar retorno como carta libre
|
||||
cosmos-header = cosmos · { $title } · Asc { $asc }° · MC { $mc }°
|
||||
cosmos-demo-title = Carta de muestra (Lima)
|
||||
cosmos-demo-subtitle = computada por cosmos-engine (VSOP2013)
|
||||
cosmos-status = { $ms } ms · { $layers } capas · { $overlays } overlays · { $aspects } aspectos
|
||||
cosmos-status-error = error: { $err }
|
||||
cosmos-overlay-transit = tránsito
|
||||
cosmos-overlay-progression = progresión
|
||||
cosmos-overlay-solar-arc = arco solar
|
||||
cosmos-overlay-uranian = uraniano
|
||||
cosmos-overlay-lots = lotes
|
||||
cosmos-overlay-fixed-stars = est. fijas
|
||||
cosmos-overlay-midpoints = puntos medios
|
||||
cosmos-harmonic-label = armónico
|
||||
cosmos-empty = (vacío)
|
||||
cosmos-tile-carta = carta
|
||||
cosmos-tile-modulos = módulos
|
||||
cosmos-tile-armonico = armónico
|
||||
cosmos-tile-cuerpos = cuerpos
|
||||
cosmos-tile-aspectos = aspectos
|
||||
cosmos-tile-box-graph = aspectarian
|
||||
cosmos-tile-cualidades = cualidades
|
||||
cosmos-elementos = elementos
|
||||
cosmos-modalidades = modalidades
|
||||
cosmos-polaridad = polaridad
|
||||
cosmos-elem-fuego = fuego
|
||||
cosmos-elem-tierra = tierra
|
||||
cosmos-elem-aire = aire
|
||||
cosmos-elem-agua = agua
|
||||
cosmos-mod-cardinal = cardinal
|
||||
cosmos-mod-fijo = fijo
|
||||
cosmos-mod-mutable = mutable
|
||||
cosmos-pol-yang = yang
|
||||
cosmos-pol-yin = yin
|
||||
cosmos-tile-astrocarto = astrocartografía
|
||||
cosmos-astrocarto-leyenda = MC sólido · IC punteado · Asc/Desc curvas · • lugar natal
|
||||
cosmos-tile-cartas = cartas guardadas
|
||||
cosmos-cartas-duplicar = + duplicar la actual
|
||||
cosmos-cartas-vacio = (vacío — duplicá la actual o copiá JSONs al dir)
|
||||
cosmos-tile-corpus = corpus
|
||||
cosmos-tile-lotes = lotes
|
||||
cosmos-tile-estrellas-fijas = estrellas fijas
|
||||
cosmos-tile-puntos-medios = puntos medios
|
||||
cosmos-corpus-header = { $pasajes } pasajes · { $huecos } huecos · { $total } combinaciones
|
||||
cosmos-corpus-vacio = (sin pasajes — escribí el corpus en cosmos-corpus/ejemplo.ron)
|
||||
cosmos-tile-uraniano = dial uraniano 90°
|
||||
cosmos-tile-cross-transit = cross · tránsito
|
||||
cosmos-tile-cross-progression = cross · progresión
|
||||
cosmos-tile-cross-solar-arc = cross · arco solar
|
||||
|
||||
# === wawa-explorer (Wawa image browser) ===
|
||||
wawa-marker-via-aoe = · via AoE
|
||||
wawa-marker-searching = · buscando…
|
||||
wawa-marker-fetch-failed = · fetch falló
|
||||
wawa-marker-not-in-image = · (no en imagen)
|
||||
wawa-iface-ok = · AoE iface: { $name }
|
||||
wawa-iface-err = · AoE: sin interfaz
|
||||
wawa-header-error = wawa-explorer · error: { $err }
|
||||
wawa-header = wawa-explorer · { $source } · { $bytes } bytes · v{ $version } · cursor sector { $cursor } · { $objects } objetos{ $iface }
|
||||
wawa-detail-empty = (seleccioná un objeto del tree)
|
||||
wawa-detail-title = objeto { $hash } · { $bytes } bytes · { $children } hijos{ $origen }
|
||||
wawa-detail-title-missing = objeto { $hash } · no presente localmente
|
||||
wawa-detail-payload-header = payload (primeros 256 bytes):
|
||||
wawa-detail-children-header = hijos:
|
||||
wawa-detail-child-missing = (no en imagen)
|
||||
wawa-detail-searching-aoe-1 = buscando en la red local (AoE)…
|
||||
wawa-detail-searching-aoe-2 = broadcast SolicitarObjeto, espera ProveedorObjeto con hash verificado.
|
||||
wawa-detail-fetch-error-1 = último intento de AoE falló:
|
||||
wawa-detail-fetch-error-2 = podés reintentar con el botón debajo.
|
||||
wawa-detail-needs-fetch-1 = este objeto está referenciado por un padre pero no vive en la imagen local.
|
||||
wawa-detail-needs-fetch-2 = podés pedirlo a peers Wawa de la red local (AoE, iface `{ $iface }`).
|
||||
wawa-detail-aoe-disabled-1 = este objeto está referenciado por un padre pero no vive en la imagen local.
|
||||
wawa-detail-aoe-disabled-2 = AoE deshabilitado: { $why }
|
||||
wawa-detail-aoe-disabled-3 = pasá `<iface>` como segundo argumento de CLI o ejecutá con CAP_NET_RAW (`sudo setcap cap_net_raw=eip <binario>`).
|
||||
wawa-btn-fetch = fetch from peers
|
||||
wawa-btn-retry-fetch = reintentar fetch from peers
|
||||
# menú principal
|
||||
wawa-menu-file = Archivo
|
||||
wawa-menu-reload = Recargar imagen
|
||||
wawa-menu-quit = Salir
|
||||
wawa-menu-view = Ver
|
||||
wawa-menu-fetch = Traer nodo por AoE
|
||||
wawa-menu-theme = Cambiar tema
|
||||
wawa-menu-help = Ayuda
|
||||
wawa-menu-about = Acerca de
|
||||
# menú contextual sobre el nodo seleccionado
|
||||
wawa-ctx-select = Seleccionar
|
||||
wawa-ctx-expand = Expandir
|
||||
wawa-ctx-collapse = Contraer
|
||||
wawa-ctx-fetch = Traer por AoE
|
||||
|
||||
# === minga-explorer (repo browser) ===
|
||||
minga-header-loaded = Repo: { $path } · reload { $ms } ms
|
||||
minga-header-searching = Buscando repo en { $path }…
|
||||
minga-error-read = no pude leer repo { $path }: { $err }
|
||||
minga-card-nodes-title = Nodos AST
|
||||
minga-card-nodes-desc = fragments parseados del código
|
||||
minga-card-attestations-title = Atestaciones
|
||||
minga-card-attestations-desc = firmas Ed25519 sobre los nodos
|
||||
minga-card-mst-title = Claves MST
|
||||
minga-card-mst-desc = entradas del Merkle Search Tree
|
||||
minga-empty = Esperando primer refresh…
|
||||
minga-menu-file = Archivo
|
||||
minga-menu-view = Ver
|
||||
minga-menu-help = Ayuda
|
||||
minga-menu-refresh = Refrescar
|
||||
minga-menu-quit = Salir
|
||||
minga-menu-theme = Cambiar tema
|
||||
minga-menu-about = Acerca de
|
||||
minga-menu-context-title = Repo
|
||||
|
||||
# === nakui-explorer (event log) ===
|
||||
nakui-explorer-header = Log: { $path } · { $entries } entries ({ $seeds } seeds, { $morphisms } morphisms) · reload { $ms } ms
|
||||
nakui-explorer-breakdown = breakdown: { $parts }
|
||||
|
||||
# === supay (doom) ===
|
||||
supay-mode-real = ENGINE REAL
|
||||
supay-mode-stub = STUB
|
||||
supay-view-fb = view=FB (F3→3D)
|
||||
supay-view-3d = view=3D (F3→FB)
|
||||
supay-header = { $title } · tick { $tick } · { $mode } · { $view } · { $scene }
|
||||
supay-stub-title = supay-doom-llimphi corre en modo STUB
|
||||
supay-stub-step-1 = Cloná doomgeneric
|
||||
supay-stub-step-1-cmd = cd 02_ruway/supay/supay-core/vendor && git clone https://github.com/ozkl/doomgeneric.git
|
||||
supay-stub-step-2 = Bajá el WAD shareware al cwd
|
||||
supay-stub-step-2-cmd = curl -O https://distro.ibiblio.org/slitaz/sources/packages/d/doom1.wad
|
||||
supay-stub-step-3 = Volvé a correr
|
||||
supay-stub-step-3-cmd = cargo run -p supay-doom-llimphi --release
|
||||
supay-stub-footer = doomgeneric (C) avanza a 35 Hz; el framebuffer 320×200 ARGB se pinta en aspect-fit.
|
||||
supay-controls-hint = WASD · Ctrl disp · Space usa · Tab map · F3 vista · F4 mira · F5 viñeta · F6 HUD · F7 sombras · F8 fogonazo · F9 oclusión · F10 luz-mobj · F11 rim-arma · F12 salir
|
||||
supay-stub-controls-hint = F3 alterna FB/3D · F12 cierra la ventana
|
||||
|
||||
# === shuma-shell ===
|
||||
shuma-label-launcher = Launcher
|
||||
shuma-label-command = Command
|
||||
shuma-label-shell = Shell
|
||||
shuma-label-matilda = Matilda
|
||||
shuma-label-canvas = Lienzo
|
||||
shuma-label-monitors = Monitores
|
||||
shuma-empty-main-incompat = Módulo Main no compatible
|
||||
shuma-empty-no-tabs = Sin tabs configuradas.
|
||||
shuma-empty-no-tabs-compat = Este módulo no puede ser tab.
|
||||
shuma-empty-no-data-linux = sin datos (¿no es Linux?)
|
||||
shuma-empty-no-data = sin datos
|
||||
shuma-stat-samples = muestras: { $have } / { $total }
|
||||
|
||||
# === nahual (visores) ===
|
||||
nahual-image-unsupported = formato no soportado (sólo PNG/JPEG en esta build)
|
||||
|
||||
# === greeter (mirada login) ===
|
||||
greeter-subtitle = iniciá tu sesión
|
||||
greeter-label-user = usuario
|
||||
greeter-label-password = contraseña
|
||||
greeter-placeholder-user = ingresá tu usuario
|
||||
greeter-status-authenticating = verificando…
|
||||
greeter-error-empty-user = ingresá un usuario
|
||||
|
||||
# === nakui (ERP shell) ===
|
||||
nakui-header = Nakui · { $count } módulo(s)
|
||||
nakui-sidebar-modules = Módulos ({ $count })
|
||||
nakui-sidebar-menu = Menú
|
||||
nakui-empty-no-modules = Sin módulos cargados
|
||||
nakui-empty-pick-menu = Elegí un menú en la barra lateral
|
||||
nakui-empty-pick-module = Elegí un módulo en la barra lateral
|
||||
nakui-pending-edit = edición pendiente: requiere meta-form Llimphi
|
||||
nakui-pending-render-detail = render pendiente: requiere meta-form Llimphi
|
||||
nakui-pending-render-dashboard = render pendiente: requiere dashboard Llimphi
|
||||
|
||||
# === pluma (editor DAG) ===
|
||||
pluma-tone-valid = coherente
|
||||
pluma-tone-pending = por evaluar
|
||||
pluma-tone-conflict = en conflicto
|
||||
|
||||
# === gioser-edit (editor de código) ===
|
||||
edit-status-find = find · Ctrl+G siguiente · Esc cierra
|
||||
edit-status-goto-def-waiting = goto-def · esperando LSP…
|
||||
edit-status-references-waiting = references · esperando LSP…
|
||||
edit-status-rename-input = rename · Enter aplica · Esc cancela
|
||||
edit-status-rename-waiting = rename → «{ $name }» · esperando LSP…
|
||||
edit-status-rename-error = rename · error en { $path }: { $err }
|
||||
edit-status-rename-done = rename · { $files } archivos · { $bytes } bytes
|
||||
edit-status-formatting-waiting = formatting · esperando LSP…
|
||||
edit-status-formatting-done = formatting · aplicado
|
||||
edit-status-goto-def-at = goto-def · { $path }:{ $line }
|
||||
edit-status-goto-def-error = goto-def · error abriendo { $path }: { $err }
|
||||
edit-status-saved = guardado · { $path }
|
||||
edit-status-save-error = error guardando: { $err }
|
||||
edit-header-hint = Ctrl+Shift+P palette · Ctrl+P files · Ctrl+Shift+F search
|
||||
edit-status-position = Ln { $line }, Col { $col } · { $lang }
|
||||
|
||||
# === chasqui-explorer (mónadas) ===
|
||||
chasqui-header = Engine '{ $engine }' · { $count } mónada(s) · socket: { $socket } ({ $src }){ $watching }
|
||||
chasqui-header-watching = · watching: { $name }
|
||||
chasqui-header-searching = Buscando daemon chasqui vía brahman-broker…
|
||||
chasqui-field-id = id: { $id }
|
||||
chasqui-field-watching = watching: { $name }
|
||||
chasqui-field-keywords = keywords: { $keywords }
|
||||
chasqui-field-path = path: { $path }
|
||||
chasqui-field-model = model: { $name }
|
||||
|
||||
# === wawa-panel (panel de control del SO wawa) ===
|
||||
wawa-panel-title = wawa · panel de control
|
||||
wawa-panel-cat-appearance = Apariencia
|
||||
wawa-panel-cat-language = Idioma
|
||||
wawa-panel-cat-apps = Aplicaciones
|
||||
wawa-panel-cat-monitor = Monitor
|
||||
wawa-panel-cat-modules = Módulos
|
||||
wawa-panel-cat-about = Acerca de
|
||||
wawa-panel-section-appearance-hint = Variante del tema y acento.
|
||||
wawa-panel-section-language-hint = Idioma del sistema y formato de hora.
|
||||
wawa-panel-section-apps-hint = Lanzá las apps nativas de wawa.
|
||||
wawa-panel-section-monitor-hint = Estado vivo del sistema.
|
||||
wawa-panel-section-modules-hint = Activar o desactivar piezas del SO.
|
||||
wawa-panel-section-about-hint = Información del sistema operativo.
|
||||
wawa-panel-label-variant = Variante
|
||||
wawa-panel-label-accent = Acento
|
||||
wawa-panel-label-language = Idioma
|
||||
wawa-panel-label-clock = Reloj
|
||||
wawa-panel-variant-dark = Oscuro
|
||||
wawa-panel-variant-light = Claro
|
||||
wawa-panel-variant-aurora = Aurora
|
||||
wawa-panel-variant-sunset = Sunset
|
||||
wawa-panel-clock-24h = 24 h
|
||||
wawa-panel-clock-12h = 12 h
|
||||
wawa-panel-stat-time = Hora
|
||||
wawa-panel-stat-uptime = Uptime
|
||||
wawa-panel-stat-mem = Memoria
|
||||
wawa-panel-stat-load = Carga
|
||||
wawa-panel-stat-host = Host
|
||||
wawa-panel-stat-kernel = Kernel
|
||||
wawa-panel-action-launch = Lanzar
|
||||
wawa-panel-action-save = Guardar config
|
||||
wawa-panel-action-reset = Restablecer
|
||||
wawa-panel-saved = Configuración guardada en { $path }
|
||||
wawa-panel-reset = Configuración restablecida a valores por defecto
|
||||
wawa-panel-menu-file = Archivo
|
||||
wawa-panel-menu-view = Ver
|
||||
wawa-panel-menu-help = Ayuda
|
||||
wawa-panel-menu-quit = Salir
|
||||
wawa-panel-status-hint = ↑↓ navegar · Enter activar · Ctrl+S guardar · Esc salir
|
||||
wawa-panel-about-name = Sistema
|
||||
wawa-panel-about-version = Versión
|
||||
wawa-panel-about-kernel = Núcleo
|
||||
wawa-panel-about-toolkit = Toolkit
|
||||
wawa-panel-about-blurb = wawa es el sistema operativo de la suite gioser. El kernel arje y las apps llimphi sobre un userland mínimo.
|
||||
wawa-panel-mod-mirada = mirada · compositor wayland
|
||||
wawa-panel-mod-shuma = shuma · empaquetado y release
|
||||
wawa-panel-mod-chasqui = chasqui · correo y mensajería
|
||||
wawa-panel-mod-akasha = akasha · canal de actualizaciones
|
||||
wawa-panel-mod-minga = minga · almacenamiento p2p
|
||||
wawa-panel-mod-agora = agora · plaza pública
|
||||
wawa-panel-mod-on = encendido
|
||||
wawa-panel-mod-off = apagado
|
||||
|
||||
# === mirada-asistente ===
|
||||
# App Llimphi que traduce lenguaje natural a comandos de mirada-ctl
|
||||
# consultando un LLM. La IA propone, el humano confirma antes de ejecutar.
|
||||
asistente-title = carmen · asistente
|
||||
asistente-sub = describí lo que querés hacer; el asistente propone, vos confirmás.
|
||||
asistente-placeholder = ¿qué querés hacer? (Enter para preguntar, Esc para limpiar)
|
||||
asistente-banner-no-llm = LLM no disponible: { $motivo }
|
||||
asistente-status-pensando = pensando…
|
||||
asistente-boton-ejecutar = Ejecutar
|
||||
asistente-boton-descartar = Descartar
|
||||
asistente-ejecutado-ok = ✓ { $accion } ejecutado
|
||||
asistente-ejecutado-fallo = ✗ { $accion } falló
|
||||
asistente-error-transporte = transporte: { $motivo }
|
||||
asistente-error-sin-llm = LLM no inicializado
|
||||
asistente-error-sin-json = respuesta sin JSON: { $crudo }
|
||||
asistente-error-accion-vacia = propuesta sin accion: { $crudo }
|
||||
asistente-error-json-invalido = JSON no reconocido: { $crudo }
|
||||
asistente-error-spawn = spawn falló: { $err } (¿está mirada-ctl en PATH?)
|
||||
asistente-cero-salida = (sin salida)
|
||||
asistente-codigo-salida = código { $codigo }
|
||||
asistente-error-accion-desconocida = el LLM propuso una accion desconocida: { $accion }
|
||||
|
||||
|
||||
# === ayni-llimphi ===
|
||||
ayni-menu-admitir = Admitir seleccionado
|
||||
ayni-menu-atestar = Atestar seleccionado
|
||||
ayni-menu-expulsar = Expulsar seleccionado
|
||||
ayni-menu-enviar-msg = Enviar mensaje
|
||||
ayni-menu-adjuntar = Adjuntar archivo…
|
||||
ayni-menu-acuse = Acuse de recibo
|
||||
ayni-menu-cifrado = Cifrado E2EE
|
||||
ayni-menu-recibos = Recibos de lectura
|
||||
ayni-menu-comandos-barra = Comandos de la barra /
|
||||
ayni-label-gente-miembros = GENTE — miembros
|
||||
ayni-label-otros-vistos = otros vistos
|
||||
ayni-label-acciones = acciones
|
||||
ayni-label-elige-alguien = elegí a alguien arriba
|
||||
ayni-btn-admitir = admitir
|
||||
ayni-btn-atestar = atestar
|
||||
ayni-btn-expulsar = expulsar
|
||||
ayni-btn-acuse = acuse
|
||||
ayni-label-confianza = confianza (saltos)
|
||||
ayni-label-sin-atestaciones = — sin atestaciones —
|
||||
ayni-label-sin-mensajes = — sin mensajes. Escribí abajo (o /ayuda para comandos). —
|
||||
ayni-compose-placeholder = escribí un mensaje, o /adjuntar <ruta>, /atestar <hex> …
|
||||
ayni-btn-enviar = enviar
|
||||
|
||||
# === chaka-app-llimphi ===
|
||||
chaka-menu-run = Ejecutar
|
||||
chaka-menu-run-pipeline = Correr pipeline
|
||||
chaka-tab-output = Salida
|
||||
chaka-tab-rust = Rust generado
|
||||
chaka-tab-diag = Diagnósticos
|
||||
chaka-btn-run = Correr
|
||||
chaka-corpus-empty = corpus vacío
|
||||
chaka-corpus-header = CORPUS
|
||||
chaka-editor-placeholder = seleccioná un programa del corpus
|
||||
chaka-no-file = sin archivo
|
||||
chaka-banner-open-corpus = abrí un programa del corpus a la izquierda
|
||||
chaka-banner-step-limit = shadow ⚠ se agotó el tope de pasos (¿bucle sin fin?)
|
||||
chaka-banner-pipeline-error = el pipeline falló — ver tab «Diag» para detalles
|
||||
chaka-status-no-open-file = no hay archivo abierto para guardar
|
||||
chaka-about-text = chaka · transpilador COBOL → Rust · pipeline lex→parse→ir→codegen→shadow
|
||||
|
||||
# === chasqui-explorer-llimphi ===
|
||||
chasqui-explorer-ctx-detail = Ver detalle
|
||||
chasqui-explorer-monad-label = Mónada
|
||||
chasqui-explorer-monad-stats = { $count } files · ent { $entropy } · { $lens }
|
||||
|
||||
# === media-app ===
|
||||
media-settings-tab-audio = Audio
|
||||
media-settings-tab-video = Video
|
||||
media-settings-tab-playback = Reproducción
|
||||
media-settings-tab-bars = Barras
|
||||
media-settings-tab-controls = Controles
|
||||
media-audio-volume = Volumen
|
||||
media-audio-eq = Ecualizador
|
||||
media-audio-normalization = Normalización
|
||||
media-audio-lufs-target = Objetivo LUFS
|
||||
media-audio-downmix = Downmix estéreo
|
||||
media-video-color = Color
|
||||
media-video-enable = Activar
|
||||
media-video-brightness = Brillo
|
||||
media-video-contrast = Contraste
|
||||
media-video-gamma = Gamma
|
||||
media-video-saturation = Saturación
|
||||
media-video-hue = Matiz
|
||||
media-video-orientation = Orientación
|
||||
media-video-rotation = Rotación
|
||||
media-video-rotate-cw = rotar 90°
|
||||
media-video-flip-h = Espejo H
|
||||
media-video-flip-v = Espejo V
|
||||
media-action-reset = reset
|
||||
media-action-cycle = ciclar
|
||||
media-playback-playlist = Playlist
|
||||
media-playback-resume = Reanudar al abrir
|
||||
media-playback-repeat = Repetición
|
||||
media-playback-shuffle = Aleatorio
|
||||
media-playback-subtitles = Subtítulos
|
||||
media-playback-autoload-sidecar = Auto-cargar sidecar
|
||||
media-playback-sub-delay = Desfase (ms)
|
||||
media-playback-font-size = Tamaño de letra
|
||||
media-playback-behavior = Comportamiento
|
||||
media-playback-crossfade = Crossfade (s)
|
||||
media-controls-header = Controles (teclado)
|
||||
media-controls-hint = Editá controles.ron y apretá F5 para reasignar teclas. El editor visual de atajos llega después.
|
||||
media-bars-header = Barras de controles — clic en un item lo quita
|
||||
media-bars-bar-label = Barra
|
||||
media-bars-remove-bar = − quitar barra
|
||||
media-bars-add-bar = + barra nueva
|
||||
media-bars-add-items-to = Agregar items a:
|
||||
media-settings-footer = Se guarda en config.ron · Esc cierra · en Barras: clic en un item lo quita, ‹ › reordenan
|
||||
media-playlist-header = Lista de reproducción — clic en una pista para saltar
|
||||
media-playlist-empty = Sin lista de reproducción.
|
||||
media-win-config-title = Configuración — media
|
||||
media-win-playlist-title = Lista de reproducción — media
|
||||
media-help-title = media · atajos
|
||||
media-help-group-playback = Reproducción
|
||||
media-help-toggle = Mostrar/ocultar esta ayuda
|
||||
media-help-close = Cerrar la ayuda
|
||||
media-help-reload = Recargar controles.ron en caliente
|
||||
media-menu-capture-frame = Capturar fotograma
|
||||
media-menu-record = Grabar / detener
|
||||
media-menu-reload-controls = Recargar controles
|
||||
media-menu-playback = Reproducción
|
||||
media-menu-play-pause = Reproducir / pausar
|
||||
media-menu-seek-back = Retroceder
|
||||
media-menu-seek-fwd = Avanzar
|
||||
media-menu-prev-track = Pista anterior
|
||||
media-menu-next-track = Pista siguiente
|
||||
media-menu-volume-up = Subir volumen
|
||||
media-menu-volume-down = Bajar volumen
|
||||
media-menu-playlist = Lista de reproducción
|
||||
media-menu-visualizers = Visualizadores de audio
|
||||
media-menu-shortcuts-help = Ayuda de atajos
|
||||
media-ctx-stop-record = Detener grabación
|
||||
media-ctx-record-audio = Grabar audio
|
||||
|
||||
# === mirada-app-llimphi ===
|
||||
mirada-menu-open-window = Abrir ventana
|
||||
mirada-menu-open-output = Abrir monitor
|
||||
mirada-menu-close-focused = Cerrar enfocada
|
||||
mirada-menu-window = Ventana
|
||||
mirada-win-promote = Promover a maestra
|
||||
mirada-win-float = Flotar / anclar
|
||||
mirada-win-fullscreen = Pantalla completa
|
||||
mirada-win-scratchpad = Enviar al scratchpad
|
||||
mirada-win-label-fallback = ventana
|
||||
mirada-layout-cycle = Ciclar layout
|
||||
mirada-layout-master-stack = Maestro + pila
|
||||
mirada-layout-monocle = Monóculo
|
||||
mirada-layout-grid = Rejilla
|
||||
mirada-layout-columns = Columnas
|
||||
mirada-layout-rows = Filas
|
||||
mirada-layout-centered = Maestro centrado
|
||||
mirada-layout-spiral = Espiral
|
||||
mirada-layout-shrink = Achicar maestra
|
||||
mirada-layout-grow = Agrandar maestra
|
||||
mirada-output-next = Siguiente monitor
|
||||
mirada-status-body-connected = Cuerpo conectado
|
||||
mirada-status-simulation = simulación — sin Cuerpo
|
||||
mirada-status-keymap-reloaded = keymap recargado
|
||||
mirada-status-keymap-invalid = keymap inválido
|
||||
mirada-label-layout = layout
|
||||
mirada-label-focus = foco
|
||||
mirada-label-output = salida
|
||||
mirada-label-workspace = escritorio
|
||||
mirada-canvas-empty-hint = escritorio vacío — pulsa n para abrir una ventana
|
||||
mirada-win-kind-fullscreen = · pantalla completa ·
|
||||
mirada-win-kind-floating = · ventana flotante ·
|
||||
mirada-win-kind-surface = · superficie del Cuerpo ·
|
||||
|
||||
# === mirada-greeter ===
|
||||
mirada-greeter-menu-session = Sesión
|
||||
mirada-greeter-session-submit = Iniciar sesión
|
||||
mirada-greeter-session-goto-user = Ir a usuario
|
||||
mirada-greeter-session-goto-pass = Ir a contraseña
|
||||
mirada-greeter-label-desktop = Escritorio
|
||||
mirada-greeter-btn-submit = Entrar
|
||||
mirada-greeter-btn-submitting = Entrando…
|
||||
mirada-greeter-hint-nav = ↑/↓: escritorio · Enter: entrar
|
||||
mirada-greeter-hint-console = Ctrl+Alt+F1…F12: consola · Ctrl+Alt+⌫: salir
|
||||
|
||||
# === nakui-explorer-llimphi ===
|
||||
nakui-explorer-menu-refresh-log = Refrescar log
|
||||
nakui-explorer-ctx-view-detail = Ver detalle
|
||||
nakui-explorer-ctx-refresh-log = Refrescar log
|
||||
nakui-explorer-ctx-entry-fallback = Entrada
|
||||
|
||||
# === nakui-sheet-llimphi ===
|
||||
nakui-sheet-ctx-clear = Limpiar
|
||||
nakui-sheet-fmt-number = Formato: Número
|
||||
nakui-sheet-fmt-currency = Formato: Moneda $
|
||||
nakui-sheet-fmt-percent = Formato: Porcentaje
|
||||
nakui-sheet-fmt-general = Formato: General
|
||||
nakui-sheet-freeze-here = Inmovilizar paneles aquí
|
||||
nakui-sheet-unfreeze = Liberar paneles
|
||||
nakui-sheet-pivot = Tabla dinámica…
|
||||
nakui-sheet-menu-cell-cut = Cortar celda
|
||||
nakui-sheet-menu-cell-copy = Copiar celda
|
||||
nakui-sheet-menu-cell-paste = Pegar celda
|
||||
nakui-sheet-menu-cell-clear = Limpiar celda
|
||||
nakui-sheet-menu-bar-cut = Cortar texto
|
||||
nakui-sheet-menu-bar-copy = Copiar texto
|
||||
nakui-sheet-menu-bar-paste = Pegar texto
|
||||
nakui-sheet-menu-bar-select-all = Seleccionar todo (texto)
|
||||
nakui-sheet-menu-import-csv = Importar CSV
|
||||
nakui-sheet-menu-export-csv = Exportar CSV
|
||||
nakui-sheet-menu-about = Acerca de Nakui Sheet
|
||||
nakui-sheet-formula-placeholder = ingresa fórmula o valor
|
||||
nakui-sheet-pivot-title = Tabla dinámica
|
||||
nakui-sheet-pivot-close = ✕ Esc
|
||||
nakui-sheet-pivot-group-by = Agrupar por «
|
||||
nakui-sheet-pivot-over = sobre
|
||||
nakui-sheet-pivot-with-header = c/encab.
|
||||
nakui-sheet-pivot-no-header = s/encab.
|
||||
nakui-sheet-pivot-more-groups = grupos
|
||||
nakui-sheet-pivot-total = TOTAL
|
||||
nakui-sheet-pivot-groups = grupos
|
||||
nakui-sheet-pivot-rows = filas
|
||||
nakui-sheet-pivot-hint = A función · G grupo · V valor · H encabezado · Esc cerrar
|
||||
|
||||
# === paloma-llimphi ===
|
||||
paloma-status-init = paloma · sin sincronizar
|
||||
paloma-status-search-semantic = búsqueda semántica (rimay): pendiente — usando exacta
|
||||
paloma-status-view-rich = HTML enriquecido vía puriy: pendiente (texto despojado por ahora)
|
||||
paloma-status-no-recipient = no se puede enviar: falta un destinatario válido
|
||||
paloma-status-sent = enviado
|
||||
paloma-status-sent-signed = enviado · firmado (Ed25519)
|
||||
paloma-placeholder-search = Buscar… ( / )
|
||||
paloma-btn-compose = ✎ Redactar
|
||||
paloma-nav-calendar = Calendario
|
||||
paloma-nav-contacts = Contactos
|
||||
paloma-nav-soon = pronto
|
||||
paloma-empty-threads = Bandeja vacía
|
||||
paloma-empty-search = sin coincidencias
|
||||
paloma-search-exact = Exacta
|
||||
paloma-search-semantic = Semántica
|
||||
paloma-no-subject = (sin asunto)
|
||||
paloma-placeholder-read = Elegí un hilo para leerlo
|
||||
paloma-btn-reply = Responder
|
||||
paloma-btn-forward = Reenviar
|
||||
paloma-btn-star = Destacar
|
||||
paloma-btn-starred = Destacado
|
||||
paloma-btn-mark-unread = Marcar no leído
|
||||
paloma-btn-mark-read = Marcar leído
|
||||
paloma-btn-view-rich = Ver HTML enriquecido
|
||||
paloma-msg-to-label = para
|
||||
paloma-sig-verified = firmado
|
||||
paloma-sig-invalid = firma inválida
|
||||
paloma-compose-new = Mensaje nuevo
|
||||
paloma-compose-reply-title = Responder
|
||||
paloma-compose-placeholder-to = Para: nombre <correo@dominio>
|
||||
paloma-compose-placeholder-cc = Cc: (opcional)
|
||||
paloma-compose-placeholder-subject = Asunto
|
||||
paloma-compose-placeholder-body = Escribí tu mensaje…
|
||||
paloma-compose-sign = Firmar (Ed25519)
|
||||
paloma-compose-send = Enviar
|
||||
|
||||
# === pluma-notebook-llimphi ===
|
||||
pluma-notebook-fit-all = Ajustar todo
|
||||
pluma-notebook-center = Centrar
|
||||
pluma-notebook-zoom-reset = Zoom 100%
|
||||
|
||||
# === raymi-llimphi ===
|
||||
raymi-tab-calendar = Calendario
|
||||
raymi-tab-contacts = Contactos
|
||||
raymi-view-month = Mes
|
||||
raymi-view-week = Semana
|
||||
raymi-view-day = Día
|
||||
raymi-btn-new-event = + Evento
|
||||
raymi-btn-today = Hoy
|
||||
raymi-btn-new-contact = + Contacto
|
||||
raymi-no-events = sin eventos
|
||||
raymi-all-day = todo el día
|
||||
raymi-no-contacts = sin contactos
|
||||
raymi-search-contact-placeholder = 🔍 Buscar contacto…
|
||||
raymi-select-contact-hint = Elegí un contacto
|
||||
raymi-title-edit-event = Editar evento
|
||||
raymi-title-new-event = Nuevo evento
|
||||
raymi-title-edit-contact = Editar contacto
|
||||
raymi-title-new-contact = Nuevo contacto
|
||||
raymi-change-cycle = cambiar
|
||||
raymi-field-summary = Asunto
|
||||
raymi-field-all-day = Día completo
|
||||
raymi-field-apply-to = Aplicar a
|
||||
raymi-field-calendar = Calendario
|
||||
raymi-field-date = Fecha
|
||||
raymi-field-start = Inicio
|
||||
raymi-field-end = Fin
|
||||
raymi-field-location = Lugar
|
||||
raymi-ph-location = Lugar (opcional)
|
||||
raymi-field-description = Descripción
|
||||
raymi-ph-description = Notas (opcional)
|
||||
raymi-field-attendees = Invitados
|
||||
raymi-ph-invitee = Nombre <correo> · Enter
|
||||
raymi-field-repeat = Repetir
|
||||
raymi-field-every = Cada
|
||||
raymi-field-days = Días
|
||||
raymi-field-ends = Termina
|
||||
raymi-field-name = Nombre
|
||||
raymi-field-emails = Correos
|
||||
raymi-field-phones = Teléfonos
|
||||
raymi-field-org = Organización
|
||||
raymi-field-note = Nota
|
||||
raymi-ph-full-name = Nombre y apellido
|
||||
raymi-ph-emails = correo@dominio, otro@…
|
||||
raymi-ph-phones = +58 412…, …
|
||||
raymi-ph-org = Empresa (opcional)
|
||||
raymi-ph-note = Nota (opcional)
|
||||
raymi-scope-series = Toda la serie
|
||||
raymi-scope-this-only = Esta instancia
|
||||
raymi-scope-this-and-future = Esta y siguientes
|
||||
raymi-repeat-none = No se repite
|
||||
raymi-repeat-daily = Diariamente
|
||||
raymi-repeat-weekly = Semanalmente
|
||||
raymi-repeat-monthly = Mensualmente
|
||||
raymi-repeat-yearly = Anualmente
|
||||
raymi-unit-days = día(s)
|
||||
raymi-unit-weeks = semana(s)
|
||||
raymi-unit-months = mes(es)
|
||||
raymi-unit-years = año(s)
|
||||
raymi-end-never = Sin fin
|
||||
raymi-end-count = Tras N veces
|
||||
raymi-end-until = Hasta fecha
|
||||
raymi-status-no-calendars = no hay calendarios donde crear un evento
|
||||
raymi-status-no-books = no hay libretas donde crear un contacto
|
||||
raymi-status-invalid-datetime = fecha u hora inválida (usá AAAA-MM-DD y HH:MM)
|
||||
raymi-status-contact-needs-name = el contacto necesita un nombre
|
||||
|
||||
# === shuma-shell-llimphi ===
|
||||
shuma-shell-clear-input = Limpiar entrada
|
||||
shuma-shell-clear-screen = Limpiar pantalla
|
||||
shuma-shell-cancel-cmd = Cancelar comando
|
||||
shuma-shell-about = Acerca de shuma
|
||||
|
||||
# === supay-app-llimphi ===
|
||||
supay-hud-health = VIDA
|
||||
supay-hud-ammo = MUNICION
|
||||
supay-hud-target = OBJETIVO
|
||||
supay-action-fire = Disparar
|
||||
supay-action-reset = Reiniciar partida
|
||||
supay-menu-play = Jugar
|
||||
supay-status-game-over = fin de partida
|
||||
supay-status-victory = victoria
|
||||
supay-status-dead = MUERTO
|
||||
supay-hint-space-restart = SPACE para reiniciar
|
||||
|
||||
# === wawa-panel-llimphi ===
|
||||
wawa-panel-status-config-updated = ↻ config actualizada desde el bus
|
||||
wawa-panel-ctx-refresh-monitor = Refrescar monitor
|
||||
wawa-panel-autosave-ok = ↻ aplicado
|
||||
@@ -0,0 +1,724 @@
|
||||
# rimay-localize — qu-PE (Runasimi, variante sureña).
|
||||
#
|
||||
# Nota para el revisor humano: este catálogo es un PUNTO DE PARTIDA
|
||||
# escrito por un desarrollador no nativo. Las formas elegidas siguen
|
||||
# fuentes accesibles (AMLQ, Cusqueño escrito) pero piden corrección por
|
||||
# alguien con dominio del idioma. Pluralización (sufijo -kuna), ergativo
|
||||
# (-pa, -wan) y cortesía (-yki) son las áreas más sensibles.
|
||||
|
||||
# === acciones genéricas ===
|
||||
save = Waqaychay
|
||||
load = Apamuy
|
||||
open = Kichay
|
||||
close = Wisq'ay
|
||||
cancel = Saqiy
|
||||
confirm = Allinmi
|
||||
yes = Arí
|
||||
no = Manan
|
||||
delete = Pichay
|
||||
edit = Hukchay
|
||||
new = Musuq
|
||||
|
||||
# === estado ===
|
||||
play = Qallariy
|
||||
pause = Samay
|
||||
resume = Kutiy
|
||||
stop = Sayachiy
|
||||
|
||||
# === menús ===
|
||||
file = Qillqa
|
||||
view = Qhaway
|
||||
help = Yanapay
|
||||
settings = Allichana
|
||||
exit = Lluqsiy
|
||||
|
||||
# === chrome común (reutilizable; PUNTO DE PARTIDA, pide revisión nativa) ===
|
||||
search = Maskay
|
||||
language = Rimay
|
||||
undo = Kutichiy
|
||||
redo = Kutirichiy
|
||||
cut = Kuchuy
|
||||
copy = Iskaychay
|
||||
paste = Llut'ay
|
||||
select-all = Llapanta akllay
|
||||
open-dots = Kichay…
|
||||
save-as = Wak hina waqaychay…
|
||||
close-tab = Wisq'ay
|
||||
find-in-file = Qillqapi maskay
|
||||
find-in-project = Llapanpi maskay
|
||||
symbols = Unanchakuna
|
||||
goto-definition = Sut'ipi riy
|
||||
terminal = Terminal
|
||||
command-palette = Kamachiykuna
|
||||
minimap = Huch'uy mapa
|
||||
cycle-theme = Rikch'aq tikray
|
||||
editing = Hukchana
|
||||
about = Imamanta
|
||||
refresh = Musuqyachiy
|
||||
reconnect = Kutillamanta tinkiy
|
||||
|
||||
# === nada (qillqa llamk'ana) ===
|
||||
nada-tagline = Llimphi patapi kamachiq qillqaq
|
||||
|
||||
# === niveles de mensaje ===
|
||||
info = Willay
|
||||
warning = Yuyaymanay
|
||||
error = Pantay
|
||||
success = Allinmi
|
||||
|
||||
# === interpolación ===
|
||||
welcome-user = Allin hamusqaykim, { $name }.
|
||||
items-count = { $count } imaymana.
|
||||
|
||||
# === dominium (chawpi pachapi pukllachiq) ===
|
||||
dominium-status-running = ● purichkan
|
||||
dominium-status-paused = ‖ samachkan
|
||||
dominium-status-line = dominium · chawpi pacha · wiñay { $epoch } · thaski { $tick }
|
||||
dominium-btn-pause = ‖ Samay
|
||||
dominium-btn-resume = ▶ Kutiy
|
||||
dominium-btn-reseed = ↺ Watiq taqraay
|
||||
dominium-btn-create-concept = ✦ Yuyay ruway
|
||||
dominium-btn-seed-pack = ✚ Taqra churay
|
||||
dominium-btn-clear = ✖ Pichay
|
||||
dominium-btn-save = 💾 Waqaychay
|
||||
dominium-btn-load-saved = 📂 Waqaychasqa apamuy
|
||||
dominium-btn-load-named = ✓ «{ $name }» apamuy
|
||||
dominium-header-sim = [ PUKLLAY ]
|
||||
dominium-header-conceptos = [ YUYAYKUNA ]
|
||||
dominium-header-metricas = [ TUPUCHIQKUNA ]
|
||||
dominium-header-editar = [ HUKCHAY ]
|
||||
dominium-active-count = { $count } kawsachkan
|
||||
dominium-stat-population = Runa hunt'ay
|
||||
dominium-stat-materia = Materia
|
||||
dominium-stat-oro = Quri
|
||||
dominium-stat-energia = Kallpa
|
||||
dominium-stat-epoca = Wiñay
|
||||
dominium-stat-gini-energia = Gini kallpa
|
||||
dominium-stat-edad-media = Watayuq chawpi
|
||||
dominium-stat-var-psi-orden = Var ψ kamachiy
|
||||
dominium-stat-var-psi-miedo = Var ψ manchakuy
|
||||
dominium-stat-var-psi-curiosidad = Var ψ tapukuy
|
||||
dominium-stat-var-psi-corruptib = Var ψ ismuriy
|
||||
dominium-action-mover = → kuyuy
|
||||
dominium-action-extraer = → hurquy
|
||||
dominium-action-sincronizar = → tinkichiy
|
||||
dominium-action-intercambiar = → chhalaway
|
||||
dominium-action-replicar = → kikinchay
|
||||
dominium-action-degradar = → uray
|
||||
dominium-slider-nombre = suti
|
||||
dominium-slider-radius = mukmu
|
||||
dominium-slider-materia = materia
|
||||
dominium-slider-psique = nuna
|
||||
dominium-slider-poder = atiy
|
||||
dominium-slider-oro = quri
|
||||
dominium-label-hack = hack:
|
||||
|
||||
# === cosmos (overlay módulos) ===
|
||||
cosmos-btn-save-transit = 💾 Purichiqta qispi qillqaman waqaychay
|
||||
cosmos-btn-save-progressed = 💾 Wiñasqata qispi qillqaman waqaychay
|
||||
cosmos-btn-save-return = 💾 Kutiqta qispi qillqaman waqaychay
|
||||
cosmos-header = cosmos · { $title } · Asc { $asc }° · MC { $mc }°
|
||||
cosmos-demo-title = Qhawanapaq qillqa (Lima)
|
||||
cosmos-demo-subtitle = cosmos-engine yupan (VSOP2013)
|
||||
cosmos-status = { $ms } ms · { $layers } qatakuna · { $overlays } overlays · { $aspects } aspectos
|
||||
cosmos-status-error = pantasqa: { $err }
|
||||
cosmos-overlay-transit = puriq
|
||||
cosmos-overlay-progression = wiñay
|
||||
cosmos-overlay-solar-arc = inti arco
|
||||
cosmos-overlay-uranian = uraniano
|
||||
cosmos-overlay-lots = lote
|
||||
cosmos-overlay-fixed-stars = qulluy
|
||||
cosmos-overlay-midpoints = chawpi
|
||||
cosmos-harmonic-label = armónico
|
||||
cosmos-empty = (manaña)
|
||||
cosmos-tile-carta = qillqa
|
||||
cosmos-tile-modulos = módulos
|
||||
cosmos-tile-armonico = armónico
|
||||
cosmos-tile-cuerpos = ukhukuna
|
||||
cosmos-tile-aspectos = aspectos
|
||||
cosmos-tile-box-graph = aspectarian
|
||||
cosmos-tile-cualidades = sayaykuna
|
||||
cosmos-elementos = elementos
|
||||
cosmos-modalidades = modalidades
|
||||
cosmos-polaridad = iskaynin
|
||||
cosmos-elem-fuego = nina
|
||||
cosmos-elem-tierra = allpa
|
||||
cosmos-elem-aire = wayra
|
||||
cosmos-elem-agua = unu
|
||||
cosmos-mod-cardinal = cardinal
|
||||
cosmos-mod-fijo = thatkiqnin
|
||||
cosmos-mod-mutable = mutable
|
||||
cosmos-pol-yang = yang
|
||||
cosmos-pol-yin = yin
|
||||
cosmos-tile-astrocarto = astrocartografía
|
||||
cosmos-astrocarto-leyenda = MC q'ipi · IC chiqan · Asc/Desc qinqu · • paqarisqa
|
||||
cosmos-tile-cartas = waqaychasqa qillqakuna
|
||||
cosmos-cartas-duplicar = + kunan iskayachay
|
||||
cosmos-cartas-vacio = (chusaq — kunanta iskayachay icha JSON-kunata churay)
|
||||
cosmos-tile-corpus = corpus
|
||||
cosmos-tile-lotes = lote
|
||||
cosmos-tile-estrellas-fijas = qulluy
|
||||
cosmos-tile-puntos-medios = chawpi
|
||||
cosmos-corpus-header = { $pasajes } pasajes · { $huecos } pisi · { $total } tinkunakuna
|
||||
cosmos-corpus-vacio = (mana pasajes — cosmos-corpus/ejemplo.ron qillqay)
|
||||
cosmos-tile-uraniano = uraniano 90° muyu
|
||||
cosmos-tile-cross-transit = cross · puriq
|
||||
cosmos-tile-cross-progression = cross · wiñay
|
||||
cosmos-tile-cross-solar-arc = cross · inti arco
|
||||
|
||||
# === wawa-explorer (Wawa imagen qhawana) ===
|
||||
wawa-marker-via-aoe = · AoE-pi
|
||||
wawa-marker-searching = · maskachkan…
|
||||
wawa-marker-fetch-failed = · fetch pantasqa
|
||||
wawa-marker-not-in-image = · (mana imagenpi)
|
||||
wawa-iface-ok = · AoE iface: { $name }
|
||||
wawa-iface-err = · AoE: mana interfaz
|
||||
wawa-header-error = wawa-explorer · pantay: { $err }
|
||||
wawa-header = wawa-explorer · { $source } · { $bytes } bytes · v{ $version } · cursor sector { $cursor } · { $objects } imaymana{ $iface }
|
||||
wawa-detail-empty = (huk imaymanata akllariy tree-pi)
|
||||
wawa-detail-title = imaymana { $hash } · { $bytes } bytes · { $children } wawa{ $origen }
|
||||
wawa-detail-title-missing = imaymana { $hash } · mana kaypi
|
||||
wawa-detail-payload-header = payload (ñawpaq 256 bytes):
|
||||
wawa-detail-children-header = wawakuna:
|
||||
wawa-detail-child-missing = (mana imagenpi)
|
||||
wawa-detail-searching-aoe-1 = local red AoE-pi maskachkan…
|
||||
wawa-detail-searching-aoe-2 = broadcast SolicitarObjeto, suyay ProveedorObjeto verified hash-niyuq.
|
||||
wawa-detail-fetch-error-1 = AoE intento pantasqa:
|
||||
wawa-detail-fetch-error-2 = kay botón qhipata watiq maskayta atinki.
|
||||
wawa-detail-needs-fetch-1 = kay imaymana huk tayta-pi nisqa, ichaqa mana local imagen-pi kawsachkan.
|
||||
wawa-detail-needs-fetch-2 = local red Wawa peer-kuna-mantapis mañakuyta atinki (AoE, iface `{ $iface }`).
|
||||
wawa-detail-aoe-disabled-1 = kay imaymana huk tayta-pi nisqa, ichaqa mana local imagen-pi kawsachkan.
|
||||
wawa-detail-aoe-disabled-2 = AoE wisq'asqa: { $why }
|
||||
wawa-detail-aoe-disabled-3 = CLI iskaynin parlachi-pi `<iface>` churay icha CAP_NET_RAW-wan kachay (`sudo setcap cap_net_raw=eip <binario>`).
|
||||
wawa-btn-fetch = peer-kuna-manta apamuy
|
||||
wawa-btn-retry-fetch = peer-kuna-manta watiq apamuy
|
||||
# hatun menú
|
||||
wawa-menu-file = Khipu
|
||||
wawa-menu-reload = Mosoqmanta kichay
|
||||
wawa-menu-quit = Lloqsiy
|
||||
wawa-menu-view = Qhaway
|
||||
wawa-menu-fetch = AoE-wan nodo apamuy
|
||||
wawa-menu-theme = Tema tikray
|
||||
wawa-menu-help = Yanapay
|
||||
wawa-menu-about = Paymanta
|
||||
# akllasqa nodopi contextual menú
|
||||
wawa-ctx-select = Akllay
|
||||
wawa-ctx-expand = Kichariy
|
||||
wawa-ctx-collapse = Wisqay
|
||||
wawa-ctx-fetch = AoE-wan apamuy
|
||||
|
||||
# === minga-explorer (repo qhawana) ===
|
||||
minga-header-loaded = Repo: { $path } · watiq apamuy { $ms } ms
|
||||
minga-header-searching = { $path }-pi repo maskachkan…
|
||||
minga-error-read = { $path } repo mana ñawinchayta atinichu: { $err }
|
||||
minga-card-nodes-title = AST Yuyay
|
||||
minga-card-nodes-desc = código-manta parsesqa fragments
|
||||
minga-card-attestations-title = Firmasqakuna
|
||||
minga-card-attestations-desc = nodos hawapi Ed25519 firmakuna
|
||||
minga-card-mst-title = MST Llaves
|
||||
minga-card-mst-desc = Merkle Search Tree-pi yaykuq
|
||||
minga-empty = Ñawpaq refresh suyachkan…
|
||||
minga-menu-file = Willay
|
||||
minga-menu-view = Qhaway
|
||||
minga-menu-help = Yanapay
|
||||
minga-menu-refresh = Musuqchay
|
||||
minga-menu-quit = Lluqsiy
|
||||
minga-menu-theme = Tema tikray
|
||||
minga-menu-about = Paymanta
|
||||
minga-menu-context-title = Repo
|
||||
|
||||
# === nakui-explorer (event log) ===
|
||||
nakui-explorer-header = Log: { $path } · { $entries } yaykuq ({ $seeds } seeds, { $morphisms } morphisms) · watiq apamuy { $ms } ms
|
||||
nakui-explorer-breakdown = rakiy: { $parts }
|
||||
|
||||
# === supay (doom) ===
|
||||
supay-mode-real = MOTOR PAQARIQ
|
||||
supay-mode-stub = STUB
|
||||
supay-view-fb = qhaway=FB (F3→3D)
|
||||
supay-view-3d = qhaway=3D (F3→FB)
|
||||
supay-header = { $title } · thaski { $tick } · { $mode } · { $view } · { $scene }
|
||||
supay-stub-title = supay-doom-llimphi STUB modo-pi purichkan
|
||||
supay-stub-step-1 = doomgeneric-ta apamuy
|
||||
supay-stub-step-1-cmd = cd 02_ruway/supay/supay-core/vendor && git clone https://github.com/ozkl/doomgeneric.git
|
||||
supay-stub-step-2 = WAD shareware-ta cwd-man apamuy
|
||||
supay-stub-step-2-cmd = curl -O https://distro.ibiblio.org/slitaz/sources/packages/d/doom1.wad
|
||||
supay-stub-step-3 = Watiq kachay
|
||||
supay-stub-step-3-cmd = cargo run -p supay-doom-llimphi --release
|
||||
supay-stub-footer = doomgeneric (C) 35 Hz-pi puriy; framebuffer 320×200 ARGB aspect-fit-wan llimpisqa.
|
||||
supay-controls-hint = WASD · Ctrl tuksiy · Space kichay · Tab mapa · F3 qhaway · F4 ñawi · F5 llantu · F6 HUD · F7 ll-chaki · F8 q'ancha · F9 hark'ay · F10 mobj-k'anchay · F11 maki-k'anchay · F12 lluqsiy
|
||||
supay-stub-controls-hint = F3 FB/3D tikray · F12 wisq'ay
|
||||
|
||||
# === shuma-shell ===
|
||||
shuma-label-launcher = Launcher
|
||||
shuma-label-command = Kamachiq
|
||||
shuma-label-shell = Shell
|
||||
shuma-label-matilda = Matilda
|
||||
shuma-label-canvas = Rikukuy
|
||||
shuma-label-monitors = Qhawaqkuna
|
||||
shuma-empty-main-incompat = Main yanapakuq mana atinmanchu
|
||||
shuma-empty-no-tabs = Mana tabs churaqa.
|
||||
shuma-empty-no-tabs-compat = Kay yanapakuq mana tab kayta atinmanchu.
|
||||
shuma-empty-no-data-linux = mana willay (¿manachu Linux?)
|
||||
shuma-empty-no-data = mana willay
|
||||
shuma-stat-samples = qhawasqakuna: { $have } / { $total }
|
||||
|
||||
# === nahual (qhawanakuna) ===
|
||||
nahual-image-unsupported = mana atisqa formato (kay build-pi PNG/JPEG sapanlla)
|
||||
|
||||
# === greeter (mirada login) ===
|
||||
greeter-subtitle = sesionniykita qallariy
|
||||
greeter-label-user = sutiyki
|
||||
greeter-label-password = pakasqa rimay
|
||||
greeter-placeholder-user = sutiykita churay
|
||||
greeter-status-authenticating = qhawachkani…
|
||||
greeter-error-empty-user = sutiyki churay
|
||||
|
||||
# === nakui (ERP shell) ===
|
||||
nakui-header = Nakui · { $count } yanapakuq
|
||||
nakui-sidebar-modules = Yanapakuqkuna ({ $count })
|
||||
nakui-sidebar-menu = Akllana
|
||||
nakui-empty-no-modules = Mana yanapakuq apamusqa
|
||||
nakui-empty-pick-menu = Akllanata akllariy lateral barrapi
|
||||
nakui-empty-pick-module = Yanapakuqta akllariy lateral barrapi
|
||||
nakui-pending-edit = hukchay suyaykuchkan: meta-form Llimphi munakun
|
||||
nakui-pending-render-detail = qhawachiy suyaykuchkan: meta-form Llimphi munakun
|
||||
nakui-pending-render-dashboard = qhawachiy suyaykuchkan: dashboard Llimphi munakun
|
||||
|
||||
# === pluma (DAG hukchaq) ===
|
||||
pluma-tone-valid = khuska
|
||||
pluma-tone-pending = qhawana
|
||||
pluma-tone-conflict = ch'aqwaypi
|
||||
|
||||
# === gioser-edit (qillqa hukchaq) ===
|
||||
edit-status-find = maskay · Ctrl+G qatiq · Esc wisq'ay
|
||||
edit-status-goto-def-waiting = goto-def · LSP suyaykuchkan…
|
||||
edit-status-references-waiting = references · LSP suyaykuchkan…
|
||||
edit-status-rename-input = sutichay · Enter ruway · Esc saqiy
|
||||
edit-status-rename-waiting = sutichay → «{ $name }» · LSP suyaykuchkan…
|
||||
edit-status-rename-error = sutichay · pantay { $path }: { $err }
|
||||
edit-status-rename-done = sutichay · { $files } qillqa · { $bytes } bytes
|
||||
edit-status-formatting-waiting = patachay · LSP suyaykuchkan…
|
||||
edit-status-formatting-done = patachay · churasqa
|
||||
edit-status-goto-def-at = goto-def · { $path }:{ $line }
|
||||
edit-status-goto-def-error = goto-def · pantay kichaspa { $path }: { $err }
|
||||
edit-status-saved = waqaychasqa · { $path }
|
||||
edit-status-save-error = pantay waqaychaspa: { $err }
|
||||
edit-header-hint = Ctrl+Shift+P akllana · Ctrl+P qillqakuna · Ctrl+Shift+F maskay
|
||||
edit-status-position = Ln { $line }, Col { $col } · { $lang }
|
||||
|
||||
# === chasqui-explorer (mónadas) ===
|
||||
chasqui-header = Engine '{ $engine }' · { $count } mónada · socket: { $socket } ({ $src }){ $watching }
|
||||
chasqui-header-watching = · qhawachkan: { $name }
|
||||
chasqui-header-searching = Chasqui daemonta maskaspa brahman-brokerwan…
|
||||
chasqui-field-id = id: { $id }
|
||||
chasqui-field-watching = qhawachkan: { $name }
|
||||
chasqui-field-keywords = rimaykuna: { $keywords }
|
||||
chasqui-field-path = ñan: { $path }
|
||||
chasqui-field-model = modelo: { $name }
|
||||
|
||||
# === wawa-panel (wawa SO panilninkuna) ===
|
||||
wawa-panel-title = wawa · kamachiy panil
|
||||
wawa-panel-cat-appearance = Rikch'aynin
|
||||
wawa-panel-cat-language = Simi
|
||||
wawa-panel-cat-apps = Llamk'anakuna
|
||||
wawa-panel-cat-monitor = Qhaway
|
||||
wawa-panel-cat-modules = T'aqakuna
|
||||
wawa-panel-cat-about = Imaynan
|
||||
wawa-panel-section-appearance-hint = Llinphi rikch'ay, tinki ima.
|
||||
wawa-panel-section-language-hint = Sistemaq simin, pacha rikch'ay ima.
|
||||
wawa-panel-section-apps-hint = wawaq llamk'ananta kichay.
|
||||
wawa-panel-section-monitor-hint = Kunan sistemaq munay kawsaynin.
|
||||
wawa-panel-section-modules-hint = SOq t'aqankunata churay otaq qichuy.
|
||||
wawa-panel-section-about-hint = Sistemaq willaynin.
|
||||
wawa-panel-label-variant = Rikch'ay
|
||||
wawa-panel-label-accent = Tinki
|
||||
wawa-panel-label-language = Simi
|
||||
wawa-panel-label-clock = Pacha
|
||||
wawa-panel-variant-dark = Llanthu
|
||||
wawa-panel-variant-light = K'anchay
|
||||
wawa-panel-variant-aurora = Aurora
|
||||
wawa-panel-variant-sunset = Inti haykuy
|
||||
wawa-panel-clock-24h = 24 h
|
||||
wawa-panel-clock-12h = 12 h
|
||||
wawa-panel-stat-time = Pacha
|
||||
wawa-panel-stat-uptime = Sayasqa pacha
|
||||
wawa-panel-stat-mem = Yuyay
|
||||
wawa-panel-stat-load = Q'ipi
|
||||
wawa-panel-stat-host = Wasiq
|
||||
wawa-panel-stat-kernel = Sunqu
|
||||
wawa-panel-action-launch = Kichay
|
||||
wawa-panel-action-save = Waqaychay
|
||||
wawa-panel-action-reset = Kaqmanta churay
|
||||
wawa-panel-saved = Kamachiy waqaychasqa { $path } nisqapi
|
||||
wawa-panel-reset = Kamachiy kaqmanta churasqa
|
||||
wawa-panel-menu-file = Willay
|
||||
wawa-panel-menu-view = Qhaway
|
||||
wawa-panel-menu-help = Yanapay
|
||||
wawa-panel-menu-quit = Lluqsiy
|
||||
wawa-panel-status-hint = ↑↓ puriy · Enter ruway · Ctrl+S waqaychay · Esc lluqsiy
|
||||
wawa-panel-about-name = Sistema
|
||||
wawa-panel-about-version = Mit'a
|
||||
wawa-panel-about-kernel = Sunqu
|
||||
wawa-panel-about-toolkit = Llamk'ana qillqana
|
||||
wawa-panel-about-blurb = wawa kaqmi gioser suiteq sistemaynin. arje sunqu, llimphi llamk'anakuna, huch'uy userland patapi.
|
||||
wawa-panel-mod-mirada = mirada · wayland kamachiq
|
||||
wawa-panel-mod-shuma = shuma · q'ipichay, willay ima
|
||||
wawa-panel-mod-chasqui = chasqui · willasqa, chasqui ima
|
||||
wawa-panel-mod-akasha = akasha · musuqyachiy ñan
|
||||
wawa-panel-mod-minga = minga · p2p waqaychana
|
||||
wawa-panel-mod-agora = agora · llaqta plaza
|
||||
wawa-panel-mod-on = kasqa
|
||||
wawa-panel-mod-off = wañusqa
|
||||
|
||||
# === mirada-asistente ===
|
||||
# Llimphi rurana, runa siminchikmanta `mirada-ctl` kamachikunaman tikrachiq,
|
||||
# LLM-ta tapuspa. Nahual nin, runa hunisqa ruwakun.
|
||||
asistente-title = carmen · yanapaq
|
||||
asistente-sub = imatachus ruway munanki nipuway; yanapaq nin, qan hunichinki.
|
||||
asistente-placeholder = imatachus ruwayta munanki? (Enter tapunapaq, Esc pichanapaq)
|
||||
asistente-banner-no-llm = LLM mana kanchu: { $motivo }
|
||||
asistente-status-pensando = yuyaykuspa…
|
||||
asistente-boton-ejecutar = Ruway
|
||||
asistente-boton-descartar = Wikch'uy
|
||||
asistente-ejecutado-ok = ✓ { $accion } ruwasqa
|
||||
asistente-ejecutado-fallo = ✗ { $accion } pantapun
|
||||
asistente-error-transporte = apaqniyoq: { $motivo }
|
||||
asistente-error-sin-llm = LLM mana kallarisqachu
|
||||
asistente-error-sin-json = JSON mana kapuq kutichiq: { $crudo }
|
||||
asistente-error-accion-vacia = ruwana mana kanchu chay nisqaqa: { $crudo }
|
||||
asistente-error-json-invalido = JSON mana riqsisqa: { $crudo }
|
||||
asistente-error-spawn = paqarichiy pantapun: { $err } (mirada-ctl PATH-pi kachkanchu?)
|
||||
asistente-cero-salida = (mana kutichiq)
|
||||
asistente-codigo-salida = lluqsiy yupay { $codigo }
|
||||
asistente-error-accion-desconocida = LLM mana riqsisqa ruwanata nirqa: { $accion }
|
||||
|
||||
|
||||
# === ayni-llimphi ===
|
||||
ayni-menu-admitir = Chaskiy aklasqata
|
||||
ayni-menu-atestar = Reqsichiy aklasqata
|
||||
ayni-menu-expulsar = Qharquy aklasqata
|
||||
ayni-menu-enviar-msg = Willayta kachamuy
|
||||
ayni-menu-adjuntar = Huñichiy willakuyta…
|
||||
ayni-menu-acuse = Chaskisqata willakuy
|
||||
ayni-menu-cifrado = Pakasqa kachana E2EE
|
||||
ayni-menu-recibos = Leeqsisqamanta willakuykuna
|
||||
ayni-menu-comandos-barra = / barramanta kamachikuna
|
||||
ayni-label-gente-miembros = RUNAKUNA — ayllukuna
|
||||
ayni-label-otros-vistos = wakinkuna rikusqakuna
|
||||
ayni-label-acciones = ruraykuna
|
||||
ayni-label-elige-alguien = huk runata akllay
|
||||
ayni-btn-admitir = chaskiy
|
||||
ayni-btn-atestar = reqsichiy
|
||||
ayni-btn-expulsar = qharquy
|
||||
ayni-btn-acuse = chaskisqa
|
||||
ayni-label-confianza = confiyay (brincaykuna)
|
||||
ayni-label-sin-atestaciones = — mana reqsichisqachu —
|
||||
ayni-label-sin-mensajes = — mana willakuykunachu. Uray qillqay (o /ayuda kamachikuymanta). —
|
||||
ayni-compose-placeholder = willakuyta qillqay, o /adjuntar <ñan>, /atestar <hex> …
|
||||
ayni-btn-enviar = kachamuy
|
||||
|
||||
# === chaka-app-llimphi ===
|
||||
chaka-menu-run = Ruway
|
||||
chaka-menu-run-pipeline = Pipeline ruwarina
|
||||
chaka-tab-output = Lluqsiy
|
||||
chaka-tab-rust = Rust ruwasqa
|
||||
chaka-tab-diag = Taripanakuy
|
||||
chaka-btn-run = Ruway
|
||||
chaka-corpus-empty = corpus ch'uwa
|
||||
chaka-corpus-header = CORPUS
|
||||
chaka-editor-placeholder = corpus nisqamanta huk programata akllay
|
||||
chaka-no-file = mana archivoyuq
|
||||
chaka-banner-open-corpus = corpus nisqamanta huk programata lloq'e ladopi kichay
|
||||
chaka-banner-step-limit = shadow ⚠ hatun takiy tukurirqan (¿tukukunaymanchu muyuriy?)
|
||||
chaka-banner-pipeline-error = pipeline pantarqan — «Diag» pestaña-pi qhawaychis
|
||||
chaka-status-no-open-file = mana kicharisqa archivom kan waqaychananpaq
|
||||
chaka-about-text = chaka · COBOL → Rust tikrasqa · pipeline lex→parse→ir→codegen→shadow
|
||||
|
||||
# === chasqui-explorer-llimphi ===
|
||||
chasqui-explorer-ctx-detail = Qhawarisqa
|
||||
chasqui-explorer-monad-label = Mónada
|
||||
chasqui-explorer-monad-stats = { $count } archivos · ent { $entropy } · { $lens }
|
||||
|
||||
# === media-app ===
|
||||
media-settings-tab-audio = Uyariy
|
||||
media-settings-tab-video = Rikuriy
|
||||
media-settings-tab-playback = Qhawana
|
||||
media-settings-tab-bars = Wara
|
||||
media-settings-tab-controls = Kamachikuy
|
||||
media-audio-volume = K'aqay
|
||||
media-audio-eq = Tupuy
|
||||
media-audio-normalization = Allichakuy
|
||||
media-audio-lufs-target = LUFS yupay
|
||||
media-audio-downmix = Iskay qhipa
|
||||
media-video-color = Riqsinchay
|
||||
media-video-enable = Qallariy
|
||||
media-video-brightness = K'anchay
|
||||
media-video-contrast = Tupay
|
||||
media-video-gamma = Gamma
|
||||
media-video-saturation = Hunt'ay
|
||||
media-video-hue = Killayay
|
||||
media-video-orientation = Tupuy
|
||||
media-video-rotation = Muytuy
|
||||
media-video-rotate-cw = muytuy 90°
|
||||
media-video-flip-h = Lluqsinchay
|
||||
media-video-flip-v = Hawaniy
|
||||
media-action-reset = qallariy
|
||||
media-action-cycle = muyuy
|
||||
media-playback-playlist = Takiy lista
|
||||
media-playback-resume = Kutimuy
|
||||
media-playback-repeat = Kutichiy
|
||||
media-playback-shuffle = Ch'aqnay
|
||||
media-playback-subtitles = Qillqa
|
||||
media-playback-autoload-sidecar = Kikin maskay
|
||||
media-playback-sub-delay = Atiy (ms)
|
||||
media-playback-font-size = Qillqa hatun
|
||||
media-playback-behavior = Ruway
|
||||
media-playback-crossfade = Tikray (s)
|
||||
media-controls-header = Kamachikuy (teclado)
|
||||
media-controls-hint = controles.ron t'aqay F5 apakuy kachiy.
|
||||
media-bars-header = Kamachikuy warakuna — clic quichuy
|
||||
media-bars-bar-label = Wara
|
||||
media-bars-remove-bar = − wara quichuy
|
||||
media-bars-add-bar = + wara musuq
|
||||
media-bars-add-items-to = Yaykuchiy:
|
||||
media-settings-footer = config.ron waqaychakun · Esc wichukuy
|
||||
media-playlist-header = Takiy lista — clic brinkay
|
||||
media-playlist-empty = Mana lista kanchu.
|
||||
media-win-config-title = Churay — media
|
||||
media-win-playlist-title = Takiy lista — media
|
||||
media-help-title = media · iskay-kachiy
|
||||
media-help-group-playback = Qhawana
|
||||
media-help-toggle = Rikuchiy/pakay yanapay
|
||||
media-help-close = Wichukuy yanapay
|
||||
media-help-reload = controles.ron kutimuy
|
||||
media-menu-capture-frame = Qhawana aysay
|
||||
media-menu-record = Qillqay / sayay
|
||||
media-menu-reload-controls = Kamachikuy kutimuy
|
||||
media-menu-playback = Qhawana
|
||||
media-menu-play-pause = Qhaway / sayay
|
||||
media-menu-seek-back = Qipa kutiy
|
||||
media-menu-seek-fwd = Ñaupay
|
||||
media-menu-prev-track = Qipa takiy
|
||||
media-menu-next-track = Ñaupa takiy
|
||||
media-menu-volume-up = K'aqay huqariy
|
||||
media-menu-volume-down = K'aqay urmachiy
|
||||
media-menu-playlist = Takiy lista
|
||||
media-menu-visualizers = Uyariy rikuchiy
|
||||
media-menu-shortcuts-help = Iskay-kachiy yanapay
|
||||
media-ctx-stop-record = Qillqay sayay
|
||||
media-ctx-record-audio = Uyariy qillqay
|
||||
|
||||
# === mirada-app-llimphi ===
|
||||
mirada-menu-open-window = Tukuypaq qichariña
|
||||
mirada-menu-open-output = Qhawana qichariña
|
||||
mirada-menu-close-focused = Qhawasqa wichʼuña
|
||||
mirada-menu-window = Tukuy
|
||||
mirada-win-promote = Mama kamachiqman apariña
|
||||
mirada-win-float = Phawariña / sayachiña
|
||||
mirada-win-fullscreen = Llapan pantalla
|
||||
mirada-win-scratchpad = Scratchpadman kachariña
|
||||
mirada-win-label-fallback = tukuy
|
||||
mirada-layout-cycle = Churasqata muyuriña
|
||||
mirada-layout-master-stack = Mama + sarqay
|
||||
mirada-layout-monocle = Huk ñawi
|
||||
mirada-layout-grid = Chʼuqicha
|
||||
mirada-layout-columns = Sayariykuna
|
||||
mirada-layout-rows = Silaykuna
|
||||
mirada-layout-centered = Chawpipi mama
|
||||
mirada-layout-spiral = Muyuq
|
||||
mirada-layout-shrink = Mamata huchuychariña
|
||||
mirada-layout-grow = Mamata hatunychariña
|
||||
mirada-output-next = Qatiq qhawana
|
||||
mirada-status-body-connected = Cuerpo huñisqa
|
||||
mirada-status-simulation = simulación — Cuerpo illaq
|
||||
mirada-status-keymap-reloaded = keymap kutichisqa
|
||||
mirada-status-keymap-invalid = keymap mana allin
|
||||
mirada-label-layout = churasqa
|
||||
mirada-label-focus = qhawasqa
|
||||
mirada-label-output = lluqsiy
|
||||
mirada-label-workspace = llamkana
|
||||
mirada-canvas-empty-hint = llamkana ch'in — n nispa hatarichiña
|
||||
mirada-win-kind-fullscreen = · llapan pantalla ·
|
||||
mirada-win-kind-floating = · phawaq tukuy ·
|
||||
mirada-win-kind-surface = · Cuerpop sawanpi ·
|
||||
|
||||
# === mirada-greeter ===
|
||||
mirada-greeter-menu-session = Yaykuy
|
||||
mirada-greeter-session-submit = Yaykuy qallariy
|
||||
mirada-greeter-session-goto-user = Sutiyman ri
|
||||
mirada-greeter-session-goto-pass = Pasakllavaman ri
|
||||
mirada-greeter-label-desktop = Llamk'ana
|
||||
mirada-greeter-btn-submit = Yaykuy
|
||||
mirada-greeter-btn-submitting = Yaykuchkaspa…
|
||||
mirada-greeter-hint-nav = ↑/↓: llamk'ana · Enter: yaykuy
|
||||
mirada-greeter-hint-console = Ctrl+Alt+F1…F12: consola · Ctrl+Alt+⌫: lluqsiy
|
||||
|
||||
# === nakui-explorer-llimphi ===
|
||||
nakui-explorer-menu-refresh-log = Quillqa musuqyachiy
|
||||
nakui-explorer-ctx-view-detail = Qhaway chikan
|
||||
nakui-explorer-ctx-refresh-log = Quillqa musuqyachiy
|
||||
nakui-explorer-ctx-entry-fallback = Yaykuy
|
||||
|
||||
# === nakui-sheet-llimphi ===
|
||||
nakui-sheet-ctx-clear = Chuyayachiy
|
||||
nakui-sheet-fmt-number = Formato: Yupay
|
||||
nakui-sheet-fmt-currency = Formato: Qullqi $
|
||||
nakui-sheet-fmt-percent = Formato: Chunka-chunka
|
||||
nakui-sheet-fmt-general = Formato: Sapsa
|
||||
nakui-sheet-freeze-here = Kaypi suyukunata siriychiy
|
||||
nakui-sheet-unfreeze = Suyukunata kacharpay
|
||||
nakui-sheet-pivot = Tukuchiy mesa…
|
||||
nakui-sheet-menu-cell-cut = Celdata kuchuy
|
||||
nakui-sheet-menu-cell-copy = Celdata rurayta
|
||||
nakui-sheet-menu-cell-paste = Celdata churay
|
||||
nakui-sheet-menu-cell-clear = Celdata chuyayachiy
|
||||
nakui-sheet-menu-bar-cut = Simiyta kuchuy
|
||||
nakui-sheet-menu-bar-copy = Simiyta rurayta
|
||||
nakui-sheet-menu-bar-paste = Simiyta churay
|
||||
nakui-sheet-menu-bar-select-all = Llapan simiyta akllay
|
||||
nakui-sheet-menu-import-csv = CSV hayquchiy
|
||||
nakui-sheet-menu-export-csv = CSV lluqsichiy
|
||||
nakui-sheet-menu-about = Nakui Sheet haqay
|
||||
nakui-sheet-formula-placeholder = fórmula utaq valorniyuqta qillqay
|
||||
nakui-sheet-pivot-title = Tukuchiy mesa
|
||||
nakui-sheet-pivot-close = ✕ Esc
|
||||
nakui-sheet-pivot-group-by = Huñuy «
|
||||
nakui-sheet-pivot-over = patapi
|
||||
nakui-sheet-pivot-with-header = umayuq
|
||||
nakui-sheet-pivot-no-header = umachiy
|
||||
nakui-sheet-pivot-more-groups = huñuykuna
|
||||
nakui-sheet-pivot-total = TUKUY
|
||||
nakui-sheet-pivot-groups = huñuykuna
|
||||
nakui-sheet-pivot-rows = saytuqkuna
|
||||
nakui-sheet-pivot-hint = A ruwana · G huñuy · V chanin · H uma · Esc wisq'ay
|
||||
|
||||
# === paloma-llimphi ===
|
||||
paloma-status-init = paloma · mana tupachisqa
|
||||
paloma-status-search-semantic = rimay maskay: suyasqam — chiqan maskaywan
|
||||
paloma-status-view-rich = puriy HTML: suyasqam (kunanmanta llamp'u qillqa)
|
||||
paloma-status-no-recipient = mana kachayta atikuchu: mana allin chaskiqkuna
|
||||
paloma-status-sent = kachasqa
|
||||
paloma-status-sent-signed = kachasqa · firmayuq (Ed25519)
|
||||
paloma-placeholder-search = Maskay… ( / )
|
||||
paloma-btn-compose = ✎ Qillqay
|
||||
paloma-nav-calendar = Killaka
|
||||
paloma-nav-contacts = Riqsisqakuna
|
||||
paloma-nav-soon = utqaylla
|
||||
paloma-empty-threads = Ch'usaq chhawa
|
||||
paloma-empty-search = mana tupanakuychu
|
||||
paloma-search-exact = Chiqan
|
||||
paloma-search-semantic = Rimay ukhunpi
|
||||
paloma-no-subject = (mana asuntuyuq)
|
||||
paloma-placeholder-read = Uj hilota akllay leenampaq
|
||||
paloma-btn-reply = Kutichiy
|
||||
paloma-btn-forward = Watiqmanta kachay
|
||||
paloma-btn-star = Ch'askayman churay
|
||||
paloma-btn-starred = Ch'askayuq
|
||||
paloma-btn-mark-unread = Mana leesqaña nispa ch'aniyay
|
||||
paloma-btn-mark-read = Leesqaña nispa ch'aniyay
|
||||
paloma-btn-view-rich = HTML sumaqta qhaway
|
||||
paloma-msg-to-label = riqsichiq
|
||||
paloma-sig-verified = firmayuq
|
||||
paloma-sig-invalid = mana allin firma
|
||||
paloma-compose-new = Mosoj qillqa
|
||||
paloma-compose-reply-title = Kutichiy
|
||||
paloma-compose-placeholder-to = Riqsichiq: suti <correo@dominio>
|
||||
paloma-compose-placeholder-cc = Cc: (munasqayki)
|
||||
paloma-compose-placeholder-subject = Asunto
|
||||
paloma-compose-placeholder-body = Qillqay willakuyniquita…
|
||||
paloma-compose-sign = Firmay (Ed25519)
|
||||
paloma-compose-send = Kachay
|
||||
|
||||
# === pluma-notebook-llimphi ===
|
||||
pluma-notebook-fit-all = Llapallan tupuchiy
|
||||
pluma-notebook-center = Chawpiman apaykachay
|
||||
pluma-notebook-zoom-reset = Zoom 100%
|
||||
|
||||
# === raymi-llimphi ===
|
||||
raymi-tab-calendar = Killay qillqa
|
||||
raymi-tab-contacts = Rimasqakuna
|
||||
raymi-view-month = Killa
|
||||
raymi-view-week = Saqra
|
||||
raymi-view-day = Punchaw
|
||||
raymi-btn-new-event = + Kausay
|
||||
raymi-btn-today = Kunan punchaw
|
||||
raymi-btn-new-contact = + Rimasqa
|
||||
raymi-no-events = mana kausaykunachu
|
||||
raymi-all-day = punchaw tukuy
|
||||
raymi-no-contacts = mana rimasqachu
|
||||
raymi-search-contact-placeholder = 🔍 Rimasqa maskhay…
|
||||
raymi-select-contact-hint = Rimasqa akllay
|
||||
raymi-title-edit-event = Kausay tikray
|
||||
raymi-title-new-event = Musuq kausay
|
||||
raymi-title-edit-contact = Rimasqa tikray
|
||||
raymi-title-new-contact = Musuq rimasqa
|
||||
raymi-change-cycle = tikray
|
||||
raymi-field-summary = Muhu
|
||||
raymi-field-all-day = Punchaw tukuy
|
||||
raymi-field-apply-to = Riqsichiy
|
||||
raymi-field-calendar = Killay qillqa
|
||||
raymi-field-date = Punchaw killa
|
||||
raymi-field-start = Qallariy
|
||||
raymi-field-end = Tukuy
|
||||
raymi-field-location = Suytu
|
||||
raymi-ph-location = Suytu (mana wakichisqachu)
|
||||
raymi-field-description = Willakuy
|
||||
raymi-ph-description = Qillqakuna (mana wakichisqachu)
|
||||
raymi-field-attendees = Waqyasqakuna
|
||||
raymi-ph-invitee = Suti <correo> · Enter
|
||||
raymi-field-repeat = Kutichiy
|
||||
raymi-field-every = Sapa
|
||||
raymi-field-days = Punchaykuna
|
||||
raymi-field-ends = Tukun
|
||||
raymi-field-name = Suti
|
||||
raymi-field-emails = Correos
|
||||
raymi-field-phones = Qayakuna
|
||||
raymi-field-org = Llankakuna
|
||||
raymi-field-note = Qillqa
|
||||
raymi-ph-full-name = Suti tukuynin
|
||||
raymi-ph-emails = correo@dominio, sapa@…
|
||||
raymi-ph-phones = +51 9…, …
|
||||
raymi-ph-org = Llanka (mana wakichisqachu)
|
||||
raymi-ph-note = Qillqa (mana wakichisqachu)
|
||||
raymi-scope-series = Tukuy kutichiy
|
||||
raymi-scope-this-only = Kay kausay sapan
|
||||
raymi-scope-this-and-future = Kay kay qhipakuna
|
||||
raymi-repeat-none = Mana kutichisqachu
|
||||
raymi-repeat-daily = Sapa punchaw
|
||||
raymi-repeat-weekly = Sapa saqra
|
||||
raymi-repeat-monthly = Sapa killa
|
||||
raymi-repeat-yearly = Sapa wata
|
||||
raymi-unit-days = punchaw(kuna)
|
||||
raymi-unit-weeks = saqra(kuna)
|
||||
raymi-unit-months = killa(kuna)
|
||||
raymi-unit-years = wata(kuna)
|
||||
raymi-end-never = Mana tukukuqchu
|
||||
raymi-end-count = N kutimanta qhipa
|
||||
raymi-end-until = Punchaw kama
|
||||
raymi-status-no-calendars = mana killay qillqachu kanchu
|
||||
raymi-status-no-books = mana qillqa watachu kanchu
|
||||
raymi-status-invalid-datetime = punchaw utaq hora mana allinchu
|
||||
raymi-status-contact-needs-name = rimasqaqa sutita munan
|
||||
|
||||
# === shuma-shell-llimphi ===
|
||||
shuma-shell-clear-input = Yaykuna pakay
|
||||
shuma-shell-clear-screen = Rikuykuna pakay
|
||||
shuma-shell-cancel-cmd = Kamachiy saqiy
|
||||
shuma-shell-about = Shuma haqhay
|
||||
|
||||
# === supay-app-llimphi ===
|
||||
supay-hud-health = KAWSAY
|
||||
supay-hud-ammo = MAQANAKUY
|
||||
supay-hud-target = QAWAY
|
||||
supay-action-fire = Aysay
|
||||
supay-action-reset = Qallarimuy
|
||||
supay-menu-play = Pukllay
|
||||
supay-status-game-over = pukllaypi tukuy
|
||||
supay-status-victory = atiy
|
||||
supay-status-dead = WAÑUSQA
|
||||
supay-hint-space-restart = SPACE ninchu qallarimuy
|
||||
|
||||
# === wawa-panel-llimphi ===
|
||||
wawa-panel-status-config-updated = ↻ churasqa huk llaqtamanta
|
||||
wawa-panel-ctx-refresh-monitor = Mosqoychiy monitor
|
||||
wawa-panel-autosave-ok = ↻ churasqa
|
||||
@@ -0,0 +1,333 @@
|
||||
//! `rimay-localize` — i18n del escritorio gioser sobre Fluent.
|
||||
//!
|
||||
//! Disciplina (PLAN.md §6.3):
|
||||
//!
|
||||
//! - Los `*-core` agnósticos **no contienen strings de UI**. Emiten
|
||||
//! identificadores (`MsgId`) que las apps Llimphi resuelven a texto
|
||||
//! localizado aquí, al renderizar.
|
||||
//! - Un único catálogo `.ftl` por idioma vive en `locales/{lang}.ftl`,
|
||||
//! embebido en el binario vía [`include_str!`].
|
||||
//! - El locale activo es un singleton de proceso. Cambiarlo recarga el
|
||||
//! bundle pero **no** retransla automáticamente la vista — las apps
|
||||
//! Llimphi vuelven a llamar a [`t`] en el próximo `view()`.
|
||||
//!
|
||||
//! ## API mínima
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use rimay_localize as l10n;
|
||||
//!
|
||||
//! // Una vez al inicio de la app: detecta `LANG`/sistema, fallback es-PE.
|
||||
//! l10n::init();
|
||||
//!
|
||||
//! // En cualquier `view()`:
|
||||
//! let label = l10n::t("save");
|
||||
//!
|
||||
//! // Con argumentos posicionales tipo Fluent `{ $name }`:
|
||||
//! let greet = l10n::t_args("welcome-user", &[("name", "Sergio".into())]);
|
||||
//! ```
|
||||
//!
|
||||
//! ## Por qué Fluent (y no gettext / embeddings)
|
||||
//!
|
||||
//! - **Fluent** trae plurales/género/contextos declarativos en el `.ftl`,
|
||||
//! sin macros de código. Esencial para idiomas aglutinantes (quechua)
|
||||
//! donde la pluralización no es la binaria one/other del inglés.
|
||||
//! - **Embeddings** son la herramienta correcta para *búsqueda semántica*
|
||||
//! (command palette, intent → acción) — ver [`rimay-verbo`]. **No** para
|
||||
//! strings deterministas de UI.
|
||||
//!
|
||||
//! ## Alcance fuera de wawa
|
||||
//!
|
||||
//! Este crate requiere `std` y `alloc` (Fluent tira de ambos). El kernel
|
||||
//! `wawa` es `no_std` y no se localiza: emite **códigos** de error que
|
||||
//! las apps WASM por encima traducen consultando este catálogo.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use fluent_bundle::concurrent::FluentBundle;
|
||||
use fluent_bundle::{FluentArgs, FluentResource, FluentValue};
|
||||
use once_cell::sync::Lazy;
|
||||
use parking_lot::RwLock;
|
||||
use thiserror::Error;
|
||||
use tracing::warn;
|
||||
use unic_langid::LanguageIdentifier;
|
||||
|
||||
// =====================================================================
|
||||
// Catálogos embebidos
|
||||
// =====================================================================
|
||||
|
||||
/// Lista de catálogos compilada al binario. Para añadir un idioma:
|
||||
/// 1. Crear `locales/{lang}.ftl`.
|
||||
/// 2. Añadir la tupla `("{lang}", include_str!(...))` aquí.
|
||||
///
|
||||
/// **Orden = prioridad declarada del proyecto**: español primero (es el
|
||||
/// fallback y la lengua de trabajo), quechua segundo (lengua de la
|
||||
/// arquitectura del monorepo), inglés tercero (uso técnico). Cambios
|
||||
/// futuros conservan este orden por convención.
|
||||
const CATALOGS: &[(&str, &str)] = &[
|
||||
("es-PE", include_str!("../locales/es.ftl")),
|
||||
("qu-PE", include_str!("../locales/qu.ftl")),
|
||||
("en-US", include_str!("../locales/en.ftl")),
|
||||
];
|
||||
|
||||
/// Locale por defecto cuando la detección del sistema falla o pide algo
|
||||
/// que no tenemos catalogado.
|
||||
pub const FALLBACK_LOCALE: &str = "es-PE";
|
||||
|
||||
// =====================================================================
|
||||
// Errores
|
||||
// =====================================================================
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum LocalizeError {
|
||||
#[error("locale '{0}' no está catalogado")]
|
||||
UnknownLocale(String),
|
||||
#[error("parseando catálogo de '{0}': {1}")]
|
||||
CatalogParse(String, String),
|
||||
#[error("identificador de locale inválido '{0}': {1}")]
|
||||
InvalidLangId(String, String),
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Estado global
|
||||
// =====================================================================
|
||||
|
||||
struct State {
|
||||
/// Locale activo (clave de [`CATALOGS`]).
|
||||
active: String,
|
||||
/// Un bundle por catálogo embebido. Se construyen perezosamente la
|
||||
/// primera vez que un locale entra en uso y se cachean.
|
||||
bundles: HashMap<String, FluentBundle<FluentResource>>,
|
||||
}
|
||||
|
||||
impl State {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
active: FALLBACK_LOCALE.to_string(),
|
||||
bundles: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_bundle(&mut self, locale: &str) -> Result<(), LocalizeError> {
|
||||
if self.bundles.contains_key(locale) {
|
||||
return Ok(());
|
||||
}
|
||||
let src = CATALOGS
|
||||
.iter()
|
||||
.find(|(l, _)| *l == locale)
|
||||
.map(|(_, s)| *s)
|
||||
.ok_or_else(|| LocalizeError::UnknownLocale(locale.to_string()))?;
|
||||
let langid: LanguageIdentifier = locale
|
||||
.parse()
|
||||
.map_err(|e: unic_langid::LanguageIdentifierError| {
|
||||
LocalizeError::InvalidLangId(locale.to_string(), e.to_string())
|
||||
})?;
|
||||
let res = FluentResource::try_new(src.to_string()).map_err(|(_, errs)| {
|
||||
LocalizeError::CatalogParse(
|
||||
locale.to_string(),
|
||||
errs.into_iter()
|
||||
.map(|e| e.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("; "),
|
||||
)
|
||||
})?;
|
||||
let mut bundle = FluentBundle::new_concurrent(vec![langid]);
|
||||
// Fluent inserta caracteres bidi U+2068/U+2069 alrededor de los
|
||||
// placeables. En una UI de escritorio que no soporta BIDI complejo
|
||||
// (Llimphi no lo hace todavía) se ven como ◌. Los desactivamos:
|
||||
// los catálogos no mezclan RTL/LTR por ahora.
|
||||
bundle.set_use_isolating(false);
|
||||
if let Err(errs) = bundle.add_resource(res) {
|
||||
warn!(target: "rimay-localize", ?errs, "errores al añadir recurso a bundle '{locale}'");
|
||||
}
|
||||
self.bundles.insert(locale.to_string(), bundle);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
static STATE: Lazy<RwLock<State>> = Lazy::new(|| RwLock::new(State::new()));
|
||||
|
||||
// =====================================================================
|
||||
// API pública
|
||||
// =====================================================================
|
||||
|
||||
/// Inicializa el localizador detectando el locale del sistema (env
|
||||
/// `LANG`/`LC_ALL` vía [`sys_locale`]) y eligiendo el más cercano de los
|
||||
/// catálogos disponibles. Si no hay match, cae en [`FALLBACK_LOCALE`].
|
||||
///
|
||||
/// Idempotente — invocaciones sucesivas resetean el locale activo según
|
||||
/// el sistema actual. Si la app quiere fijar el locale a mano, usar
|
||||
/// [`set_locale`] después.
|
||||
pub fn init() {
|
||||
let detected = sys_locale::get_locale().unwrap_or_else(|| FALLBACK_LOCALE.to_string());
|
||||
let chosen = best_match(&detected).unwrap_or_else(|| FALLBACK_LOCALE.to_string());
|
||||
let _ = set_locale(&chosen);
|
||||
}
|
||||
|
||||
/// Cambia el locale activo. Compila el catálogo correspondiente si aún
|
||||
/// no estaba cargado.
|
||||
pub fn set_locale(locale: &str) -> Result<(), LocalizeError> {
|
||||
let mut state = STATE.write();
|
||||
state.ensure_bundle(locale)?;
|
||||
state.active = locale.to_string();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Devuelve el locale activo (clave de [`CATALOGS`]).
|
||||
pub fn current_locale() -> String {
|
||||
STATE.read().active.clone()
|
||||
}
|
||||
|
||||
/// Lista de locales disponibles (claves de [`CATALOGS`]).
|
||||
pub fn available_locales() -> Vec<&'static str> {
|
||||
CATALOGS.iter().map(|(l, _)| *l).collect()
|
||||
}
|
||||
|
||||
/// Resuelve un mensaje sin argumentos. Si el ID no existe en el catálogo
|
||||
/// activo, devuelve el propio ID — facilita ver qué falta traducir sin
|
||||
/// crashear la UI.
|
||||
pub fn t(id: &str) -> String {
|
||||
resolve(id, None)
|
||||
}
|
||||
|
||||
/// Resuelve un mensaje con argumentos posicionales tipo Fluent
|
||||
/// (`{ $name }`). Los valores se convierten a [`FluentValue`] vía la
|
||||
/// impl `From<Cow<str>>` — números pásalos pre-formateados como string
|
||||
/// si querés controlar la presentación.
|
||||
pub fn t_args(id: &str, args: &[(&str, Cow<'_, str>)]) -> String {
|
||||
let mut fa = FluentArgs::new();
|
||||
for (k, v) in args {
|
||||
fa.set(*k, FluentValue::from(v.clone().into_owned()));
|
||||
}
|
||||
resolve(id, Some(&fa))
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Internos
|
||||
// =====================================================================
|
||||
|
||||
fn resolve(id: &str, args: Option<&FluentArgs>) -> String {
|
||||
// Auto-init lazy: si `t()` se llama sin `init()` previo (típico desde
|
||||
// librerías como `cosmos-modules` que no ven el `main`), cargamos el
|
||||
// fallback en ese momento. Es un costo amortizado de una sola vez.
|
||||
if STATE.read().bundles.is_empty() {
|
||||
let mut w = STATE.write();
|
||||
if w.bundles.is_empty() {
|
||||
let _ = w.ensure_bundle(FALLBACK_LOCALE);
|
||||
w.active = FALLBACK_LOCALE.to_string();
|
||||
}
|
||||
}
|
||||
let state = STATE.read();
|
||||
let bundle = match state.bundles.get(&state.active) {
|
||||
Some(b) => b,
|
||||
None => return id.to_string(),
|
||||
};
|
||||
let Some(msg) = bundle.get_message(id) else {
|
||||
return id.to_string();
|
||||
};
|
||||
let Some(pattern) = msg.value() else {
|
||||
return id.to_string();
|
||||
};
|
||||
let mut errors = vec![];
|
||||
let s = bundle.format_pattern(pattern, args, &mut errors);
|
||||
if !errors.is_empty() {
|
||||
warn!(target: "rimay-localize", ?errors, "errores formateando '{id}' en locale '{}'", state.active);
|
||||
}
|
||||
s.into_owned()
|
||||
}
|
||||
|
||||
/// Mejor match entre un locale solicitado y los catalogados.
|
||||
///
|
||||
/// 1. Match exacto (`qu-PE` → `qu-PE`).
|
||||
/// 2. Match por lengua base ignorando región (`es-AR` → `es-PE`,
|
||||
/// `qu-BO` → `qu-PE`).
|
||||
/// 3. Sin match → `None`.
|
||||
fn best_match(requested: &str) -> Option<String> {
|
||||
if CATALOGS.iter().any(|(l, _)| *l == requested) {
|
||||
return Some(requested.to_string());
|
||||
}
|
||||
let base = requested.split(['-', '_']).next()?;
|
||||
CATALOGS
|
||||
.iter()
|
||||
.find(|(l, _)| l.split('-').next() == Some(base))
|
||||
.map(|(l, _)| l.to_string())
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Tests
|
||||
// =====================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
// Los tests comparten estado global → serialización manual.
|
||||
static SERIAL: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[test]
|
||||
fn fallback_locale_resolves() {
|
||||
let _g = SERIAL.lock().unwrap();
|
||||
set_locale("es-PE").unwrap();
|
||||
let s = t("save");
|
||||
assert_eq!(s, "Guardar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switches_to_english() {
|
||||
let _g = SERIAL.lock().unwrap();
|
||||
set_locale("en-US").unwrap();
|
||||
assert_eq!(t("save"), "Save");
|
||||
assert_eq!(t("cancel"), "Cancel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quechua_loads() {
|
||||
let _g = SERIAL.lock().unwrap();
|
||||
set_locale("qu-PE").unwrap();
|
||||
// Solo verificamos que devuelva *algo distinto* del id, no la
|
||||
// traducción literal — para no acoplar el test al fraseo exacto
|
||||
// del catálogo (que el revisor de quechua va a ajustar).
|
||||
let s = t("save");
|
||||
assert_ne!(s, "save", "qu-PE no resolvió 'save'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_id_returns_id_as_degradation() {
|
||||
let _g = SERIAL.lock().unwrap();
|
||||
set_locale("es-PE").unwrap();
|
||||
assert_eq!(t("__id_que_no_existe__"), "__id_que_no_existe__");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn args_interpolate() {
|
||||
let _g = SERIAL.lock().unwrap();
|
||||
set_locale("es-PE").unwrap();
|
||||
let s = t_args("welcome-user", &[("name", "Sergio".into())]);
|
||||
assert!(s.contains("Sergio"), "no interpoló name: {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_match_region_fallback() {
|
||||
assert_eq!(best_match("es-AR"), Some("es-PE".to_string()));
|
||||
assert_eq!(best_match("en-GB"), Some("en-US".to_string()));
|
||||
assert_eq!(best_match("qu-BO"), Some("qu-PE".to_string()));
|
||||
assert_eq!(best_match("ja-JP"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_locale_errors() {
|
||||
let err = set_locale("xx-YY").unwrap_err();
|
||||
assert!(matches!(err, LocalizeError::UnknownLocale(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn available_locales_lists_all() {
|
||||
let v = available_locales();
|
||||
assert!(v.contains(&"es-PE"));
|
||||
assert!(v.contains(&"en-US"));
|
||||
assert!(v.contains(&"qu-PE"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "wawa-config"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
directories = { workspace = true }
|
||||
notify = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
@@ -0,0 +1,35 @@
|
||||
# wawa-config — bus de configuración del SO
|
||||
|
||||
El **bus de configuración** del escritorio/SO: un archivo TOML canónico
|
||||
(`~/.config/wawa/config.toml`) + un watcher (`notify`) que reemite cambios en
|
||||
vivo, sobre una capa de sistema (`/etc/wawa/config.toml`) que el usuario puede
|
||||
override-ar. Los consumidores (apps Llimphi del escritorio) se suscriben y
|
||||
reaccionan al vuelo: cambiar theme/acento se propaga **sin reiniciar**.
|
||||
|
||||
UI-agnóstico: **no depende de `llimphi`**. El adaptador que ensambla un `Theme`
|
||||
efectivo a partir del `WawaConfig` vive en `wawa-config-llimphi`.
|
||||
|
||||
## Qué expone
|
||||
|
||||
- `WawaConfig` — la configuración (variant de theme, accent override, …).
|
||||
- Carga con merge `/etc/wawa` (sistema) bajo override de usuario.
|
||||
- Watcher (`notify`) que reemite el config al cambiar el archivo.
|
||||
|
||||
## Estado (2026-05-31)
|
||||
|
||||
### Hecho
|
||||
- Archivo canónico TOML + watcher `notify` (live reload).
|
||||
- Capa de sistema `/etc/wawa/config.toml` mergeada bajo el override de usuario.
|
||||
- Auto-apply del acento al theme global; ≈10 tests.
|
||||
- Consumido por nada, cosmos, nakui, dominium, shuma, nahual, minga, arje,
|
||||
wawa-panel y `wawactl` (CLI).
|
||||
|
||||
### Pendiente
|
||||
- Esquema de config más amplio (más que theme/acento).
|
||||
- Validación/migración de versiones del TOML.
|
||||
- Consumo desde el SO wawa bare-metal (hoy es el escritorio host).
|
||||
|
||||
## Lugar en el repo
|
||||
|
||||
`shared/wawa-config` — fuente de verdad UI-agnóstica. Adaptador de theme:
|
||||
`wawa-config-llimphi`. CLI: `wawactl`.
|
||||
@@ -0,0 +1,762 @@
|
||||
//! `wawa-config` — bus de configuración del SO wawa.
|
||||
//!
|
||||
//! Dos capas de archivos JSON canónicos actúan como medio:
|
||||
//!
|
||||
//! 1. **Sistema** — `/etc/wawa/config.json` (Linux). Defaults
|
||||
//! machine-wide; lo escribe el admin con `wawactl --system set …`
|
||||
//! (requiere root) o un instalador.
|
||||
//! 2. **Usuario** — `$XDG_CONFIG_HOME/wawa/config.json` (Linux:
|
||||
//! `~/.config/wawa/config.json`). Lo que escribe el panel y las
|
||||
//! apps; **sobreescribe** campo por campo a la capa de sistema.
|
||||
//!
|
||||
//! El panel de control y los daemons escriben; las apps Llimphi leen y
|
||||
//! se suscriben a cambios vía [`notify::RecommendedWatcher`] sobre
|
||||
//! **ambos** paths.
|
||||
//!
|
||||
//! ## Nota sobre `/etc/wawa`
|
||||
//!
|
||||
//! `/etc/` es una convención de Unix/Linux. Cuando wawa sea su propio
|
||||
//! SO (no un userland sobre Linux), esta capa se reemplazará por el
|
||||
//! mecanismo nativo de "config de sistema" que defina arje — la API
|
||||
//! pública (`load`, `system_path`, `user_path`) se mantiene; sólo
|
||||
//! cambia lo que devuelve `system_path()` adentro.
|
||||
//!
|
||||
//! Por qué archivo + `notify` y no un daemon pub-sub:
|
||||
//!
|
||||
//! * **Cero dependencias en runtime**: ninguna app necesita que un
|
||||
//! daemon esté arrancado para leer la config; basta con que el
|
||||
//! archivo exista (y si no existe, devuelve defaults).
|
||||
//! * **Auditable y editable a mano**: el archivo es JSON con `pretty`,
|
||||
//! el admin lo abre con cualquier editor o lo edita por sed/jq.
|
||||
//! * **Atomicidad simple**: `save()` escribe a `config.json.tmp` y
|
||||
//! `rename()` — los watchers ven un único evento de creación que
|
||||
//! contiene la versión completa.
|
||||
//! * **Compatible con apps existentes**: el modelo de Llimphi ya
|
||||
//! reentra al `update` vía `Handle::dispatch`; el watcher dispara
|
||||
//! un Msg del consumidor cuando llega el evento.
|
||||
//!
|
||||
//! ## Forma del archivo
|
||||
//!
|
||||
//! ```json
|
||||
//! {
|
||||
//! "theme_variant": "dark",
|
||||
//! "accent": "default",
|
||||
//! "lang": "es-PE",
|
||||
//! "timefmt_24h": true,
|
||||
//! "modules": {
|
||||
//! "mirada": true,
|
||||
//! "shuma": true,
|
||||
//! "chasqui": true,
|
||||
//! "akasha": true,
|
||||
//! "minga": true,
|
||||
//! "agora": true
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! Campos desconocidos se ignoran al deserializar; campos ausentes
|
||||
//! caen al default. Esto permite agregar nuevas keys sin romper
|
||||
//! consumidores antiguos.
|
||||
//!
|
||||
//! ## Productor
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use wawa_config::WawaConfig;
|
||||
//!
|
||||
//! let mut cfg = WawaConfig::load();
|
||||
//! cfg.theme_variant = "aurora".into();
|
||||
//! cfg.save()?;
|
||||
//! ```
|
||||
//!
|
||||
//! ## Consumidor (app Llimphi)
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use wawa_config::{WawaConfig, ConfigWatcher};
|
||||
//!
|
||||
//! // En `App::init`:
|
||||
//! let handle = handle.clone();
|
||||
//! let watcher = ConfigWatcher::spawn(move |cfg| {
|
||||
//! handle.dispatch(Msg::ConfigChanged(cfg));
|
||||
//! })?;
|
||||
//! // Guardar `watcher` en el Model para que viva todo lo que vive la app.
|
||||
//! ```
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use tracing::warn;
|
||||
|
||||
/// Nombre del subdirectorio dentro de XDG_CONFIG_HOME donde vive el
|
||||
/// archivo. Exporto la constante para que tests y herramientas
|
||||
/// externas lo puedan inspeccionar.
|
||||
pub const CONFIG_DIR: &str = "wawa";
|
||||
/// Nombre del archivo canónico.
|
||||
pub const CONFIG_FILE: &str = "config.json";
|
||||
/// Directorio de la capa de sistema en Linux. Cuando wawa sea su
|
||||
/// propio SO esta ruta se reemplaza por lo que defina arje; la API
|
||||
/// pública (`system_config_path`) se mantiene.
|
||||
pub const SYSTEM_CONFIG_DIR_LINUX: &str = "/etc/wawa";
|
||||
|
||||
/// Capa de la cual se cargó/escribió una config. Útil para herramientas
|
||||
/// que necesiten distinguir explícitamente entre sistema y usuario.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Layer {
|
||||
/// `/etc/wawa/config.json` — defaults machine-wide, requiere root.
|
||||
System,
|
||||
/// `$XDG_CONFIG_HOME/wawa/config.json` — override por usuario.
|
||||
User,
|
||||
}
|
||||
|
||||
/// Mapea el `theme_variant` de la config (lowercase, libre) al nombre
|
||||
/// canónico que reconoce `llimphi_theme::Theme::by_name` (capitalizado).
|
||||
/// Devuelve `None` si el variant no es uno de los presets conocidos —
|
||||
/// el consumidor decide qué hacer (fallback a dark, error, etc.).
|
||||
///
|
||||
/// Los presets de Llimphi tienen `name: &'static str` capitalizado;
|
||||
/// los users del CLI y el panel escriben en lowercase. Este shim
|
||||
/// mantiene a `wawa-config` UI-agnóstico (no depende de
|
||||
/// `llimphi-theme`) y a la vez evita que cada consumidor reimplemente
|
||||
/// el casing.
|
||||
pub fn canonical_theme_name(variant: &str) -> Option<&'static str> {
|
||||
match variant.to_ascii_lowercase().as_str() {
|
||||
"dark" => Some("Dark"),
|
||||
"light" => Some("Light"),
|
||||
"aurora" => Some("Aurora"),
|
||||
"sunset" => Some("Sunset"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Devuelve el color RGB de un acento por id. `default` retorna `None`
|
||||
/// para que el consumidor no toque el accent del theme base. La paleta
|
||||
/// es la misma del web (`gioser-web/styles.css`): tinte por cuadrante
|
||||
/// + accent gioser por default.
|
||||
///
|
||||
/// Es un trio RGB (no un tipo de `peniko`) para no obligar a depender
|
||||
/// de `llimphi-raster` desde acá. Los consumidores Llimphi hacen:
|
||||
///
|
||||
/// ```ignore
|
||||
/// if let Some([r,g,b]) = wawa_config::accent_rgb(&cfg.accent) {
|
||||
/// let c = llimphi_theme::Color::from_rgba8(r, g, b, 255);
|
||||
/// theme.accent = c;
|
||||
/// theme.border_focus = c;
|
||||
/// }
|
||||
/// ```
|
||||
pub fn accent_rgb(accent: &str) -> Option<[u8; 3]> {
|
||||
match accent {
|
||||
"default" => None,
|
||||
"gioser" => Some([0x6E, 0x8C, 0xDC]),
|
||||
"unanchay" => Some([0xB9, 0xC9, 0xE8]),
|
||||
"yachay" => Some([0xE8, 0xC9, 0x7A]),
|
||||
"ruway" => Some([0xE8, 0x9B, 0x6E]),
|
||||
"ukupacha" => Some([0x8F, 0xB5, 0x8C]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lista de variants de theme reconocidas — útil para validadores y
|
||||
/// generadores de docs/UI. Orden estable.
|
||||
pub const THEME_VARIANTS: &[&str] = &["dark", "light", "aurora", "sunset"];
|
||||
|
||||
/// Lista de acentos reconocidos. `"default"` significa "no override".
|
||||
pub const ACCENTS: &[&str] = &["default", "gioser", "unanchay", "yachay", "ruway", "ukupacha"];
|
||||
|
||||
/// Identificadores estables de los módulos del SO conocidos. Las apps
|
||||
/// son libres de leer/escribir otros, pero estos son los que el panel
|
||||
/// expone por default — mantenerlos como `const` ayuda a no escribir
|
||||
/// el string mal en sitios distintos.
|
||||
pub mod modules {
|
||||
pub const MIRADA: &str = "mirada";
|
||||
pub const SHUMA: &str = "shuma";
|
||||
pub const CHASQUI: &str = "chasqui";
|
||||
pub const AKASHA: &str = "akasha";
|
||||
pub const MINGA: &str = "minga";
|
||||
pub const AGORA: &str = "agora";
|
||||
|
||||
pub fn defaults() -> [(&'static str, bool); 6] {
|
||||
[
|
||||
(MIRADA, true),
|
||||
(SHUMA, true),
|
||||
(CHASQUI, true),
|
||||
(AKASHA, true),
|
||||
(MINGA, true),
|
||||
(AGORA, true),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuración del sistema operativo wawa. Serializada como el JSON
|
||||
/// del módulo. Campos nuevos se agregan con `#[serde(default = "…")]`
|
||||
/// para preservar compatibilidad hacia atrás.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WawaConfig {
|
||||
/// Variante del theme global. Coincide con
|
||||
/// `llimphi_theme::Theme::name`: `"dark"`, `"light"`, `"aurora"`,
|
||||
/// `"sunset"`.
|
||||
#[serde(default = "default_theme_variant")]
|
||||
pub theme_variant: String,
|
||||
|
||||
/// Acento. `"default"` deja el accent del theme; cualquier otro
|
||||
/// id (gioser/unanchay/yachay/ruway/ukupacha) lo sobreescribe.
|
||||
#[serde(default = "default_accent")]
|
||||
pub accent: String,
|
||||
|
||||
/// Locale activo. Acepta lo mismo que `rimay_localize::set_locale`:
|
||||
/// `"es-PE"`, `"en-US"`, `"qu-PE"`.
|
||||
#[serde(default = "default_lang")]
|
||||
pub lang: String,
|
||||
|
||||
/// Formato del reloj (true = 24h, false = 12h con am/pm).
|
||||
#[serde(default = "default_timefmt")]
|
||||
pub timefmt_24h: bool,
|
||||
|
||||
/// Estado on/off de los módulos del SO. Usa los ids de
|
||||
/// [`modules`]. BTreeMap → serializa con orden estable y diffs
|
||||
/// limpios en git.
|
||||
#[serde(default = "default_modules")]
|
||||
pub modules: BTreeMap<String, bool>,
|
||||
}
|
||||
|
||||
fn default_theme_variant() -> String {
|
||||
"dark".into()
|
||||
}
|
||||
fn default_accent() -> String {
|
||||
"default".into()
|
||||
}
|
||||
fn default_lang() -> String {
|
||||
"es-PE".into()
|
||||
}
|
||||
fn default_timefmt() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_modules() -> BTreeMap<String, bool> {
|
||||
modules::defaults()
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_string(), v))
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Default for WawaConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme_variant: default_theme_variant(),
|
||||
accent: default_accent(),
|
||||
lang: default_lang(),
|
||||
timefmt_24h: default_timefmt(),
|
||||
modules: default_modules(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WawaConfig {
|
||||
/// `true` si el módulo `id` está activo (default: activo si no se
|
||||
/// conoce — convención conservadora).
|
||||
pub fn module_enabled(&self, id: &str) -> bool {
|
||||
self.modules.get(id).copied().unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Conmuta el módulo `id`. Si no existía, lo agrega con `false`.
|
||||
pub fn toggle_module(&mut self, id: &str) {
|
||||
let v = self.modules.entry(id.to_string()).or_insert(true);
|
||||
*v = !*v;
|
||||
}
|
||||
|
||||
/// Path canónico del archivo de usuario (alias de [`user_config_path`]).
|
||||
/// `None` si la plataforma no expone un config dir (extremadamente
|
||||
/// raro fuera de embebidos).
|
||||
pub fn path() -> Option<PathBuf> {
|
||||
user_config_path()
|
||||
}
|
||||
|
||||
/// Path canónico del archivo de la capa indicada. `None` si la
|
||||
/// capa no aplica en esta plataforma (p. ej. `Layer::System` fuera
|
||||
/// de Linux).
|
||||
pub fn path_for(layer: Layer) -> Option<PathBuf> {
|
||||
match layer {
|
||||
Layer::System => system_config_path(),
|
||||
Layer::User => user_config_path(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Carga la config efectiva: defaults → capa de sistema → capa de
|
||||
/// usuario. Cada capa **sobreescribe campo por campo** lo que
|
||||
/// definió la anterior; campos ausentes preservan el valor
|
||||
/// previo. Para `modules`, el merge es key-by-key (no reemplazo
|
||||
/// total del mapa).
|
||||
///
|
||||
/// Si ningún archivo existe, o están corruptos, devuelve defaults
|
||||
/// — nunca falla. Los errores se loggean a `tracing::warn`.
|
||||
pub fn load() -> Self {
|
||||
let mut acc = serde_json::to_value(Self::default())
|
||||
.expect("WawaConfig::default siempre serializa");
|
||||
for layer in [Layer::System, Layer::User] {
|
||||
if let Some(v) = load_layer_value(layer) {
|
||||
merge_json(&mut acc, v);
|
||||
}
|
||||
}
|
||||
serde_json::from_value(acc).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Carga **sólo** la capa indicada, sin mergear con la otra. Útil
|
||||
/// para herramientas como `wawactl --system show` que necesitan
|
||||
/// inspeccionar una capa concreta. Si el archivo no existe,
|
||||
/// devuelve `None` (no defaults — distingue "ausente" de
|
||||
/// "presente con defaults"). Errores de parseo loggean warn y
|
||||
/// también devuelven `None`.
|
||||
pub fn load_layer(layer: Layer) -> Option<Self> {
|
||||
let path = Self::path_for(layer)?;
|
||||
let bytes = match std::fs::read(&path) {
|
||||
Ok(b) => b,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
|
||||
Err(e) => {
|
||||
warn!(?path, error = %e, "wawa-config: read failed");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
match serde_json::from_slice::<WawaConfig>(&bytes) {
|
||||
Ok(c) => Some(c),
|
||||
Err(e) => {
|
||||
warn!(?path, error = %e, "wawa-config: parse failed");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persiste atómicamente en la capa de **usuario** (compat con
|
||||
/// callers existentes): serializa a `config.json.tmp` y renombra
|
||||
/// sobre `config.json`. Crea el directorio padre si no existe.
|
||||
pub fn save(&self) -> Result<PathBuf, ConfigError> {
|
||||
self.save_to(Layer::User)
|
||||
}
|
||||
|
||||
/// Persiste en la capa indicada. `Layer::System` apunta a
|
||||
/// `/etc/wawa/config.json` y típicamente requiere root — devuelve
|
||||
/// `ConfigError::Io` con `PermissionDenied` si no.
|
||||
pub fn save_to(&self, layer: Layer) -> Result<PathBuf, ConfigError> {
|
||||
let path = Self::path_for(layer).ok_or(ConfigError::NoProjectDirs)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(self)?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, json)?;
|
||||
std::fs::rename(&tmp, &path)?;
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Lee y parsea una capa como `serde_json::Value`. Devuelve `None`
|
||||
/// (no defaults) si el archivo no existe o falla el parse — esto es
|
||||
/// distinto del `WawaConfig::load_layer` que también devuelve Option,
|
||||
/// pero acá trabajamos con Value para mergear sin perder "campo
|
||||
/// ausente vs explícito".
|
||||
fn load_layer_value(layer: Layer) -> Option<serde_json::Value> {
|
||||
let path = WawaConfig::path_for(layer)?;
|
||||
let bytes = match std::fs::read(&path) {
|
||||
Ok(b) => b,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
|
||||
Err(e) => {
|
||||
warn!(?path, error = %e, "wawa-config: read failed");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
match serde_json::from_slice::<serde_json::Value>(&bytes) {
|
||||
Ok(v) => Some(v),
|
||||
Err(e) => {
|
||||
warn!(?path, error = %e, "wawa-config: parse failed");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge profundo: `over` sobreescribe `base` hoja por hoja, recursivo
|
||||
/// sobre objetos JSON. Para arrays y escalares, `over` reemplaza
|
||||
/// completamente. Esto preserva la semántica "campo ausente → no
|
||||
/// modifica la capa inferior" y permite que un user override sólo
|
||||
/// algunas keys de `modules`.
|
||||
fn merge_json(base: &mut serde_json::Value, over: serde_json::Value) {
|
||||
use serde_json::Value;
|
||||
match (base, over) {
|
||||
(Value::Object(b), Value::Object(o)) => {
|
||||
for (k, v) in o {
|
||||
match b.get_mut(&k) {
|
||||
Some(existing) => merge_json(existing, v),
|
||||
None => {
|
||||
b.insert(k, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(slot, v) => *slot = v,
|
||||
}
|
||||
}
|
||||
|
||||
/// Errores de IO o serialización al persistir la config. La carga
|
||||
/// nunca falla — devuelve defaults en su lugar.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("no hay ProjectDirs en esta plataforma")]
|
||||
NoProjectDirs,
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("serde: {0}")]
|
||||
Serde(#[from] serde_json::Error),
|
||||
#[error("notify: {0}")]
|
||||
Notify(#[from] notify::Error),
|
||||
}
|
||||
|
||||
/// Path del archivo de **usuario**. El qualifier "" + organization ""
|
||||
/// se mapea a `$XDG_CONFIG_HOME/wawa/` en Linux (típicamente
|
||||
/// `~/.config/wawa/`), `~/Library/Application Support/wawa/` en
|
||||
/// macOS, `%APPDATA%/wawa/` en Windows. `None` si la plataforma no
|
||||
/// expone un config dir.
|
||||
pub fn user_config_path() -> Option<PathBuf> {
|
||||
directories::ProjectDirs::from("", "", CONFIG_DIR)
|
||||
.map(|d| d.config_dir().join(CONFIG_FILE))
|
||||
}
|
||||
|
||||
/// Path del archivo de **sistema**. `Some("/etc/wawa/config.json")`
|
||||
/// en Linux; `None` en otras plataformas (no hay convención
|
||||
/// equivalente y no vale la pena inventarla). Cuando wawa sea su
|
||||
/// propio SO, esta función devolverá el equivalente nativo y la API
|
||||
/// pública no cambia.
|
||||
pub fn system_config_path() -> Option<PathBuf> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
Some(PathBuf::from(SYSTEM_CONFIG_DIR_LINUX).join(CONFIG_FILE))
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Watcher
|
||||
// =====================================================================
|
||||
|
||||
/// Suscripción al bus. Mantenelo vivo (guardalo en el Model de tu app)
|
||||
/// para seguir recibiendo notificaciones; al dropearlo, los callbacks
|
||||
/// dejan de dispararse.
|
||||
///
|
||||
/// Observa **ambas capas** (sistema y usuario): un cambio en
|
||||
/// cualquiera dispara `on_change` con la config efectiva ya mergeada.
|
||||
/// Cada capa escucha el directorio padre con
|
||||
/// `RecursiveMode::NonRecursive` y filtra por `config.json` — así
|
||||
/// detecta tanto modificaciones in-place como reemplazos atómicos por
|
||||
/// `rename`. Si la capa de sistema no aplica en la plataforma (no
|
||||
/// Linux), o no se puede crear/observar (p. ej. `/etc/wawa` sin
|
||||
/// permisos de lectura — improbable porque `/etc/` es world-readable
|
||||
/// por convención), se ignora con un warn y el watcher sigue activo
|
||||
/// sólo sobre la capa de usuario.
|
||||
///
|
||||
/// Para evitar disparar dos veces seguidas cuando un editor escribe
|
||||
/// con la secuencia `truncate → write → close`, el watcher debouncea
|
||||
/// internamente con un timeout de ~200 ms: agrupa eventos consecutivos
|
||||
/// y emite un único callback con la última versión leída.
|
||||
pub struct ConfigWatcher {
|
||||
_watchers: Vec<RecommendedWatcher>,
|
||||
_debounce_thread: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl ConfigWatcher {
|
||||
/// Arranca el watcher. `on_change` se llama cada vez que **alguna**
|
||||
/// de las capas cambia, ya con la nueva config efectiva mergeada
|
||||
/// (sistema ← usuario). Si el parseo falla, no se invoca (se
|
||||
/// loggea como warn y se ignora hasta el próximo cambio).
|
||||
///
|
||||
/// `on_change` corre en un thread propio del watcher — para
|
||||
/// reentrar al loop de Llimphi, capturá un `Handle<Msg>` clonado
|
||||
/// y llamá `handle.dispatch(...)` dentro de la closure.
|
||||
pub fn spawn<F>(on_change: F) -> Result<Self, ConfigError>
|
||||
where
|
||||
F: FnMut(WawaConfig) + Send + 'static,
|
||||
{
|
||||
let (tx, rx) = mpsc::channel::<()>();
|
||||
let mut watchers = Vec::with_capacity(2);
|
||||
|
||||
// Capa de usuario es obligatoria; si no hay ProjectDirs es un
|
||||
// entorno raro y devolvemos error como antes.
|
||||
let user_path = user_config_path().ok_or(ConfigError::NoProjectDirs)?;
|
||||
watchers.push(spawn_layer_watcher(&user_path, tx.clone(), /*must_exist=*/ true)?);
|
||||
|
||||
// Capa de sistema es best-effort: si no aplica (no Linux), o
|
||||
// no se puede observar (sin permiso de lectura de `/etc/wawa`,
|
||||
// que es muy raro pero posible si el admin la chmodea), no
|
||||
// rompe — sólo no se entera de cambios de sistema.
|
||||
if let Some(sys_path) = system_config_path() {
|
||||
match spawn_layer_watcher(&sys_path, tx.clone(), /*must_exist=*/ false) {
|
||||
Ok(w) => watchers.push(w),
|
||||
Err(e) => warn!(?sys_path, error = %e, "wawa-config: system layer watch skipped"),
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce: junta señales durante ~200 ms y al cierre llama
|
||||
// `on_change` con la lectura más reciente (mergeada). Acepta
|
||||
// que perdamos ráfagas intermedias — solo importa el estado
|
||||
// final.
|
||||
let debounce = thread::Builder::new()
|
||||
.name("wawa-config-debounce".into())
|
||||
.spawn(move || debounce_loop(rx, Box::new(on_change)))
|
||||
.ok();
|
||||
|
||||
Ok(Self {
|
||||
_watchers: watchers,
|
||||
_debounce_thread: debounce,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_layer_watcher(
|
||||
path: &Path,
|
||||
tx: mpsc::Sender<()>,
|
||||
must_exist: bool,
|
||||
) -> Result<RecommendedWatcher, ConfigError> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| {
|
||||
ConfigError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
"config path sin parent",
|
||||
))
|
||||
})?
|
||||
.to_path_buf();
|
||||
|
||||
// Para la capa de usuario creamos el dir si falta — notify puede
|
||||
// watchear un dir vacío. Para la de sistema no lo creamos: si
|
||||
// `/etc/wawa` no existe, probablemente esta máquina no usa la capa
|
||||
// de sistema y mejor no requerir permisos de root para correr una
|
||||
// app de usuario.
|
||||
if must_exist {
|
||||
std::fs::create_dir_all(&parent)?;
|
||||
} else if !parent.exists() {
|
||||
return Err(ConfigError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"directorio de capa de sistema ausente",
|
||||
)));
|
||||
}
|
||||
|
||||
let target_name = path.file_name().map(|n| n.to_owned());
|
||||
let mut watcher =
|
||||
notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
|
||||
let event = match res {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "wawa-config: watcher error");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let is_target = match &target_name {
|
||||
Some(name) => event
|
||||
.paths
|
||||
.iter()
|
||||
.any(|p| p.file_name().map(|f| f == name.as_os_str()).unwrap_or(false)),
|
||||
None => true,
|
||||
};
|
||||
if !is_target {
|
||||
return;
|
||||
}
|
||||
if !matches!(
|
||||
event.kind,
|
||||
EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let _ = tx.send(());
|
||||
})?;
|
||||
watcher.watch(&parent, RecursiveMode::NonRecursive)?;
|
||||
Ok(watcher)
|
||||
}
|
||||
|
||||
fn debounce_loop(rx: mpsc::Receiver<()>, mut on_change: Box<dyn FnMut(WawaConfig) + Send>) {
|
||||
const QUIET: Duration = Duration::from_millis(200);
|
||||
loop {
|
||||
// Esperar al primer evento sin timeout.
|
||||
if rx.recv().is_err() {
|
||||
return;
|
||||
}
|
||||
// Drenar lo que se acumule en la ventana de quiet.
|
||||
loop {
|
||||
match rx.recv_timeout(QUIET) {
|
||||
Ok(()) => continue,
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => break,
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => return,
|
||||
}
|
||||
}
|
||||
let cfg = WawaConfig::load();
|
||||
on_change(cfg);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Tests
|
||||
// =====================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_round_trip() {
|
||||
let c = WawaConfig::default();
|
||||
let s = serde_json::to_string(&c).unwrap();
|
||||
let back: WawaConfig = serde_json::from_str(&s).unwrap();
|
||||
assert_eq!(c, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_json_uses_defaults() {
|
||||
// Sólo se aporta theme; el resto debe caer al default.
|
||||
let s = r#"{"theme_variant":"aurora"}"#;
|
||||
let c: WawaConfig = serde_json::from_str(s).unwrap();
|
||||
assert_eq!(c.theme_variant, "aurora");
|
||||
assert_eq!(c.accent, "default");
|
||||
assert_eq!(c.lang, "es-PE");
|
||||
assert!(c.timefmt_24h);
|
||||
assert!(c.module_enabled(modules::MIRADA));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_fields_ignored() {
|
||||
// Un campo extra no rompe la deserialización.
|
||||
let s = r#"{"theme_variant":"dark","unknown":42}"#;
|
||||
let _c: WawaConfig = serde_json::from_str(s).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_module_persists_value() {
|
||||
let mut c = WawaConfig::default();
|
||||
assert!(c.module_enabled(modules::MIRADA));
|
||||
c.toggle_module(modules::MIRADA);
|
||||
assert!(!c.module_enabled(modules::MIRADA));
|
||||
c.toggle_module("inexistente");
|
||||
assert!(!c.module_enabled("inexistente"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_theme_maps_variants() {
|
||||
assert_eq!(canonical_theme_name("dark"), Some("Dark"));
|
||||
assert_eq!(canonical_theme_name("LIGHT"), Some("Light"));
|
||||
assert_eq!(canonical_theme_name("Aurora"), Some("Aurora"));
|
||||
assert_eq!(canonical_theme_name("sunset"), Some("Sunset"));
|
||||
assert_eq!(canonical_theme_name("hyperdark"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accent_rgb_default_is_none() {
|
||||
assert_eq!(accent_rgb("default"), None);
|
||||
assert_eq!(accent_rgb("gioser"), Some([0x6E, 0x8C, 0xDC]));
|
||||
assert_eq!(accent_rgb("ukupacha"), Some([0x8F, 0xB5, 0x8C]));
|
||||
assert_eq!(accent_rgb("desconocido"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_user_over_system_overrides_field_by_field() {
|
||||
// Sistema define theme aurora y lang qu-PE; usuario sólo
|
||||
// sobreescribe lang. Resultado: theme aurora (de sistema),
|
||||
// lang en-US (de usuario).
|
||||
let mut base = serde_json::to_value(WawaConfig::default()).unwrap();
|
||||
let system: serde_json::Value =
|
||||
serde_json::from_str(r#"{"theme_variant":"aurora","lang":"qu-PE"}"#).unwrap();
|
||||
let user: serde_json::Value =
|
||||
serde_json::from_str(r#"{"lang":"en-US"}"#).unwrap();
|
||||
merge_json(&mut base, system);
|
||||
merge_json(&mut base, user);
|
||||
let final_cfg: WawaConfig = serde_json::from_value(base).unwrap();
|
||||
assert_eq!(final_cfg.theme_variant, "aurora");
|
||||
assert_eq!(final_cfg.lang, "en-US");
|
||||
// El resto cae al default.
|
||||
assert_eq!(final_cfg.accent, "default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_modules_is_deep_per_key() {
|
||||
// Sistema apaga mirada; usuario apaga shuma. Esperado: ambos
|
||||
// off, el resto en su default true.
|
||||
let mut base = serde_json::to_value(WawaConfig::default()).unwrap();
|
||||
let system: serde_json::Value =
|
||||
serde_json::from_str(r#"{"modules":{"mirada":false}}"#).unwrap();
|
||||
let user: serde_json::Value =
|
||||
serde_json::from_str(r#"{"modules":{"shuma":false}}"#).unwrap();
|
||||
merge_json(&mut base, system);
|
||||
merge_json(&mut base, user);
|
||||
let final_cfg: WawaConfig = serde_json::from_value(base).unwrap();
|
||||
assert!(!final_cfg.module_enabled(modules::MIRADA));
|
||||
assert!(!final_cfg.module_enabled(modules::SHUMA));
|
||||
assert!(final_cfg.module_enabled(modules::CHASQUI));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_path_only_on_linux() {
|
||||
let p = system_config_path();
|
||||
if cfg!(target_os = "linux") {
|
||||
assert_eq!(p, Some(PathBuf::from("/etc/wawa/config.json")));
|
||||
} else {
|
||||
assert!(p.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constants_match_helpers() {
|
||||
// THEME_VARIANTS y ACCENTS deben coincidir con lo que aceptan
|
||||
// los helpers — guarda contra agregar uno y olvidar el otro.
|
||||
for v in THEME_VARIANTS {
|
||||
assert!(canonical_theme_name(v).is_some(), "variant {v} sin mapeo");
|
||||
}
|
||||
for a in ACCENTS {
|
||||
// accent_rgb("default") es None por diseño; el resto debe
|
||||
// tener color asignado.
|
||||
if *a == "default" {
|
||||
assert_eq!(accent_rgb(a), None);
|
||||
} else {
|
||||
assert!(accent_rgb(a).is_some(), "accent {a} sin color");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Path absoluto del config dir de **usuario** — alias por compat.
|
||||
/// `None` si no hay ProjectDirs disponibles.
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
directories::ProjectDirs::from("", "", CONFIG_DIR).map(|d| d.config_dir().to_path_buf())
|
||||
}
|
||||
|
||||
/// Path absoluto del config dir de **sistema** (`/etc/wawa` en Linux).
|
||||
/// `None` en otras plataformas.
|
||||
pub fn system_config_dir() -> Option<PathBuf> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
Some(PathBuf::from(SYSTEM_CONFIG_DIR_LINUX))
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper opcional: agrega el `path` provisto a una lista de
|
||||
/// watchers. No es parte del flujo normal — está expuesto para
|
||||
/// herramientas que quieran observar un directorio externo (p. ej.
|
||||
/// `/etc/wawa/` para configuración del sistema vs el del usuario).
|
||||
/// El default (`spawn`) ya cubre el caso típico.
|
||||
pub fn watch_path(
|
||||
p: &Path,
|
||||
on_event: impl FnMut(notify::Event) + Send + 'static,
|
||||
) -> Result<RecommendedWatcher, ConfigError> {
|
||||
let mut on_event = on_event;
|
||||
let mut w =
|
||||
notify::recommended_watcher(move |res: notify::Result<notify::Event>| match res {
|
||||
Ok(ev) => on_event(ev),
|
||||
Err(e) => warn!(error = %e, "wawa-config: external watch error"),
|
||||
})?;
|
||||
w.watch(p, RecursiveMode::NonRecursive)?;
|
||||
Ok(w)
|
||||
}
|
||||
Reference in New Issue
Block a user