tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8
llimphi-wasm-open/src/main.rs
History
Who touched what
raw
1//! llimphi-wasm-open — abre una app WASM Tier 3 como una ventana de escritorio.
2//!
3//! Cierra el lazo de la distribución: el stack Tier 3 ya sabía resolver una app
4//! por hash, verificar su integridad y su concesión Ed25519, y materializar su
5//! `View<Msg>` en una ventana Llimphi. Lo que faltaba era el **ejecutable** que
6//! ata todo desde la línea de comandos del escritorio — el gemelo host del
7//! ejecutor de apps de wawa, y el consumidor real de `app_bus::Launch::Wasm`
8//! (que hasta ahora devolvía `Unsupported` porque "lo resuelve el chasis": este
9//! binario *es* ese chasis).
10//!
11//! Dos modos:
12//!
13//! ```text
14//! # 1. Un .wasm local — app de sólo-UI, sin permisos (camino rápido).
15//! llimphi-wasm-open app.wasm
16//!
17//! # 2. Por hash desde un CAS local — se verifica integridad y, si hay
18//! # concesión, se valida contra el anillo de confianza y se corre con
19//! # los permisos efectivos (los bits gatean qué host imports se enlazan).
20//! llimphi-wasm-open --hash <blake3-hex> --store ~/.cache/llimphi/blobs \
21//! [--grant <blake3-hex>] [--ring claves.txt] [--name "Mi App"]
22//! ```
23//!
24//! El modo hash es exactamente lo que un launcher de la UI (dock, spotlight,
25//! mirada) invocaría para un `Launch::Wasm{bytecode_hex, grant_hex}`: spawnear
26//! este binario con `--hash`/`--grant`/`--store`, manteniendo a `app-bus`
27//! liviano (sólo transporta hex, no toca el runner ni la GPU).
28
29use std::process::ExitCode;
30use std::sync::OnceLock;
31
32use format::{ConcesionCapacidad, Permisos};
33use llimphi_ui::{App, Handle, KeyEvent, View};
34use llimphi_wasm_dist::{
35 hash_from_hex, hash_to_hex, resolve_from_catalog, AppManifest, Catalog, CatalogEntry, DiskStore,
36 RunnerMsg, TrustRing, VerifiedAppExt, WasmGuest,
37};
38
39/// Lo resuelto en `main` y consumido por `Host::init` en el hilo de la UI.
40/// Cargamos el `WasmGuest` recién en `init` (no antes) porque el `Store` de
41/// wasmi vive atado a su hilo; aquí viajan sólo datos (`Send`).
42#[cfg_attr(test, derive(Debug))]
43struct LaunchSpec {
44 wasm: Vec<u8>,
45 permisos: Permisos,
46 title: String,
47}
48
49static SPEC: OnceLock<LaunchSpec> = OnceLock::new();
50
51/// El modelo del host: el guest vivo + el título que mostramos en la barra.
52struct HostModel {
53 guest: WasmGuest,
54 title: String,
55}
56
57struct Host;
58
59impl App for Host {
60 type Model = HostModel;
61 type Msg = RunnerMsg;
62
63 fn title() -> &'static str {
64 "llimphi · wasm"
65 }
66
67 fn init(_: &Handle<Self::Msg>) -> Self::Model {
68 let spec = SPEC.get().expect("LaunchSpec no inicializado");
69 let guest = WasmGuest::load(&spec.wasm, spec.permisos)
70 .unwrap_or_else(|e| panic!("cargar app WASM: {e}"));
71 HostModel {
72 guest,
73 title: spec.title.clone(),
74 }
75 }
76
77 fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
78 if let Err(e) = model.guest.apply(&msg) {
79 eprintln!("wasm dispatch: {e}");
80 }
81 model
82 }
83
84 fn view(model: &Self::Model) -> View<Self::Msg> {
85 model.guest.render()
86 }
87
88 fn window_title(model: &Self::Model) -> Option<String> {
89 Some(format!("llimphi · {}", model.title))
90 }
91
92 fn on_key(model: &Self::Model, event: &KeyEvent) -> Option<Self::Msg> {
93 model.guest.key_to_msg(event)
94 }
95
96 fn on_focus(_model: &Self::Model, id: Option<u64>) -> Option<Self::Msg> {
97 Some(WasmGuest::focus_msg(id))
98 }
99}
100
101fn main() -> ExitCode {
102 let argv: Vec<String> = std::env::args().skip(1).collect();
103 let tiene = |f: &str| argv.iter().any(|a| a == f);
104
105 // Modo registro: conectar a un registro de apps (REST en RON o directorio
106 // local) — listar/buscar y, con --ingest, descargar al CAS + catálogo. No
107 // abre ventana.
108 if tiene("--registry") {
109 return match registro(argv.iter().cloned()) {
110 Ok(msg) => {
111 println!("{msg}");
112 ExitCode::SUCCESS
113 }
114 Err(e) => {
115 eprintln!("llimphi-wasm-open --registry: {e}");
116 uso();
117 ExitCode::FAILURE
118 }
119 };
120 }
121
122 // Modo catálogo (búsqueda): listar/buscar apps por texto. No abre ventana.
123 if tiene("--list") || tiene("--search") {
124 return match buscar(argv.iter().cloned()) {
125 Ok(msg) => {
126 println!("{msg}");
127 ExitCode::SUCCESS
128 }
129 Err(e) => {
130 eprintln!("llimphi-wasm-open: {e}");
131 uso();
132 ExitCode::FAILURE
133 }
134 };
135 }
136
137 // Modo productor: publicar una app (blob al CAS + manifiesto + entrada de
138 // catálogo si se pide). No abre ventana.
139 if tiene("--install") {
140 return match instalar(argv.into_iter()) {
141 Ok(msg) => {
142 println!("{msg}");
143 ExitCode::SUCCESS
144 }
145 Err(e) => {
146 eprintln!("llimphi-wasm-open --install: {e}");
147 uso();
148 ExitCode::FAILURE
149 }
150 };
151 }
152
153 // Modo consumidor: resolver la app (por id del catálogo o por hash/archivo)
154 // y correrla según su clase.
155 let here = tiene("--here");
156 let spec = if tiene("--run") {
157 spec_desde_catalogo(argv.iter().cloned())
158 } else {
159 resolver_spec(argv.iter().cloned())
160 };
161 match spec {
162 Ok(spec) => {
163 // Una app WASI de consola muestra su salida en **shuma** (la
164 // terminal de la suite), no en una ventana de consola propia: si hay
165 // display, shuma está disponible y no nos pidieron `--here`,
166 // delegamos. `--here` (o sin display, o sin shuma) ⇒ corre local.
167 let es_wasi = llimphi_wasm_wasi::detect_kind(&spec.wasm)
168 == llimphi_wasm_wasi::WasmKind::WasiConsole;
169 if es_wasi && !here && hay_display() && delegar_a_shuma(&argv) {
170 return ExitCode::SUCCESS;
171 }
172 correr(spec)
173 }
174 Err(e) => {
175 eprintln!("llimphi-wasm-open: {e}");
176 uso();
177 ExitCode::FAILURE
178 }
179 }
180}
181
182/// ¿Hay un servidor gráfico (Wayland/X11)? Sin él no tiene sentido abrir shuma.
183fn hay_display() -> bool {
184 let set = |k: &str| std::env::var(k).map(|v| !v.is_empty()).unwrap_or(false);
185 set("WAYLAND_DISPLAY") || set("DISPLAY")
186}
187
188/// Argumentos para abrir shuma corriendo esta misma invocación en modo local:
189/// `-e <este-binario> <argv…> --here`. shuma une todo tras `-e` en una línea y
190/// la corre como primer bloque; `--here` evita que el re-lanzamiento vuelva a
191/// delegar (corre la app WASI e imprime, y shuma captura esa salida).
192fn comando_para_shuma(exe: &str, argv: &[String]) -> Vec<String> {
193 let mut v = Vec::with_capacity(argv.len() + 3);
194 v.push("-e".to_string());
195 v.push(exe.to_string());
196 v.extend(argv.iter().cloned());
197 v.push("--here".to_string());
198 v
199}
200
201/// Lanza shuma para correr esta invocación adentro. `true` si se spawneó; en
202/// error (shuma no instalado, etc.) `false` para caer a ejecución local.
203fn delegar_a_shuma(argv: &[String]) -> bool {
204 let exe = match std::env::current_exe() {
205 Ok(p) => p.to_string_lossy().to_string(),
206 Err(_) => return false,
207 };
208 std::process::Command::new("shuma-shell-llimphi")
209 .args(comando_para_shuma(&exe, argv))
210 .spawn()
211 .is_ok()
212}
213
214/// Corre la app ya resuelta según su clase: una app Tier 3 (UI) abre una
215/// ventana Llimphi; un programa WASI de consola se ejecuta y vuelca su salida
216/// al terminal, propagando el código de salida. Así el catálogo no es trampa:
217/// el WASM de consola (el grueso del ecosistema) también corre.
218fn correr(spec: LaunchSpec) -> ExitCode {
219 use llimphi_wasm_wasi::{detect_kind, run_console, WasmKind};
220
221 match detect_kind(&spec.wasm) {
222 WasmKind::WasiConsole => match run_console(&spec.wasm, &[], &[], &[]) {
223 Ok(out) => {
224 print!("{}", out.stdout_text());
225 eprint!("{}", out.stderr_text());
226 ExitCode::from(out.exit_code.clamp(0, 255) as u8)
227 }
228 Err(e) => {
229 eprintln!("llimphi-wasm-open: WASI: {e}");
230 ExitCode::FAILURE
231 }
232 },
233 // Tier 3 (o desconocido, que intentamos como UI): ventana Llimphi.
234 _ => {
235 let _ = SPEC.set(spec);
236 llimphi_ui::run::<Host>();
237 ExitCode::SUCCESS
238 }
239 }
240}
241
242fn uso() {
243 eprintln!();
244 eprintln!("uso:");
245 eprintln!(" llimphi-wasm-open <app.wasm>");
246 eprintln!(" llimphi-wasm-open --hash <hex> [--store <dir>] \\");
247 eprintln!(" [--grant <hex>] [--ring <archivo>] [--name <título>]");
248 eprintln!(" llimphi-wasm-open --list [--catalog <archivo>]");
249 eprintln!(" llimphi-wasm-open --search <texto> [--catalog <archivo>]");
250 eprintln!(" llimphi-wasm-open --run <id> [--catalog <archivo>] \\");
251 eprintln!(" [--store <dir>] [--ring <archivo>]");
252 eprintln!(" llimphi-wasm-open --install <app.wasm> --id <id> \\");
253 eprintln!(" [--name <label>] [--desc <texto>] [--grant <archivo>] \\");
254 eprintln!(" [--icon <glifo>] [--category <cat>] [--store <dir>] \\");
255 eprintln!(" [--apps-dir <dir>] [--catalog <archivo>]");
256 eprintln!(" llimphi-wasm-open --registry <descriptor.ron> [--instance <base>] \\");
257 eprintln!(" [--query <texto>] [--ingest] [--catalog <archivo>] [--store <dir>]");
258 eprintln!(" (--here fuerza la app WASI a correr local en vez de en shuma)");
259}
260
261/// Traduce los argumentos a un [`LaunchSpec`] resuelto y verificado. El modo se
262/// decide por la presencia de `--hash`: con él, resolución completa contra el
263/// CAS; sin él, el primer argumento es un `.wasm` local de sólo-UI.
264fn resolver_spec(args: impl Iterator<Item = String>) -> Result<LaunchSpec, String> {
265 let mut args = args.peekable();
266
267 let mut hash_hex: Option<String> = None;
268 let mut grant_hex: Option<String> = None;
269 let mut store_dir: Option<String> = None;
270 let mut ring_path: Option<String> = None;
271 let mut name: Option<String> = None;
272 let mut path: Option<String> = None;
273
274 while let Some(arg) = args.next() {
275 match arg.as_str() {
276 "--hash" => hash_hex = Some(next_val(&mut args, "--hash")?),
277 "--grant" => grant_hex = Some(next_val(&mut args, "--grant")?),
278 "--store" => store_dir = Some(next_val(&mut args, "--store")?),
279 "--ring" => ring_path = Some(next_val(&mut args, "--ring")?),
280 "--name" => name = Some(next_val(&mut args, "--name")?),
281 // Lo agrega la delegación a shuma; aquí sólo significa "corre local".
282 "--here" => {}
283 "-h" | "--help" => return Err("ayuda".into()),
284 otro if otro.starts_with("--") => {
285 return Err(format!("opción desconocida: {otro}"))
286 }
287 otro => path = Some(otro.to_string()),
288 }
289 }
290
291 match hash_hex {
292 // Modo distribución: hash → CAS → verificar → permisos efectivos.
293 Some(hex) => {
294 // Sin `--store` cae al CAS de blobs por defecto del escritorio
295 // (XDG cache), para que el chasis pueda spawnearnos con sólo el hash.
296 let store_dir = store_dir.unwrap_or_else(cas_por_defecto);
297 let store = DiskStore::open(&store_dir)
298 .map_err(|e| format!("abrir CAS {store_dir}: {e}"))?;
299
300 let trust = match &ring_path {
301 Some(p) => TrustRing::load(p).map_err(|e| format!("anillo {p}: {e}"))?,
302 None => TrustRing::empty(),
303 };
304
305 let bytecode = hash_from_hex(&hex).map_err(|e| format!("--hash inválido: {e}"))?;
306 let concesion = match &grant_hex {
307 Some(g) => Some(hash_from_hex(g).map_err(|e| format!("--grant inválido: {e}"))?),
308 None => None,
309 };
310 // Con concesión declaramos MAX y dejamos que la intersección con lo
311 // concedido fije los efectivos (honrar el grant completo); sin ella,
312 // app de sólo-UI (fail-closed, permisos = 0).
313 let declarados = if concesion.is_some() {
314 Permisos::MAX
315 } else {
316 0
317 };
318 let manifest = AppManifest {
319 bytecode,
320 declarados,
321 concesion,
322 };
323
324 let app = llimphi_wasm_dist::resolve_manifest(&store, &trust, &manifest)
325 .map_err(|e| format!("resolver app: {e}"))?;
326 // Para una app Tier 3 instanciamos una vez aquí para fallar temprano
327 // con un mensaje claro (un guest que importe una capacidad no
328 // concedida trap-ea al instanciar); el `WasmGuest` no cruza de hilo,
329 // así que init lo recarga. Una app WASI no se instancia con el
330 // runner Tier 3 (otro ABI) — la valida/corre `correr()`.
331 if llimphi_wasm_wasi::detect_kind(&app.wasm) != llimphi_wasm_wasi::WasmKind::WasiConsole
332 {
333 app.load().map_err(|e| format!("instanciar app: {e}"))?;
334 }
335 Ok(LaunchSpec {
336 wasm: app.wasm,
337 permisos: app.permisos,
338 title: name.unwrap_or_else(|| short_hex(&hex)),
339 })
340 }
341 // Modo local: un .wasm de sólo-UI, sin verificación ni permisos.
342 None => {
343 let path = path.ok_or("falta el archivo .wasm o --hash")?;
344 let wasm = std::fs::read(&path).map_err(|e| format!("leer {path}: {e}"))?;
345 let title = name.unwrap_or_else(|| nombre_archivo(&path));
346 Ok(LaunchSpec {
347 wasm,
348 permisos: 0,
349 title,
350 })
351 }
352 }
353}
354
355fn next_val(
356 args: &mut std::iter::Peekable<impl Iterator<Item = String>>,
357 flag: &str,
358) -> Result<String, String> {
359 args.next().ok_or_else(|| format!("{flag} requiere un valor"))
360}
361
362/// Publica una app WASM en el escritorio: mete el bytecode (y, si se da, la
363/// concesión) en el CAS local y escribe un manifiesto `<id>.toml` en el
364/// directorio de apps, para que el dock/spotlight la descubran y la lancen por
365/// la ruta de hash. Es el lado productor del lazo de distribución —
366/// el inverso de [`resolver_spec`] modo hash. Devuelve un resumen legible.
367fn instalar(args: impl Iterator<Item = String>) -> Result<String, String> {
368 let mut args = args.peekable();
369 let mut wasm_path: Option<String> = None;
370 let mut id: Option<String> = None;
371 let mut name: Option<String> = None;
372 let mut grant_file: Option<String> = None;
373 let mut icon: Option<String> = None;
374 let mut category: Option<String> = None;
375 let mut store_dir: Option<String> = None;
376 let mut apps_dir: Option<String> = None;
377 let mut desc: Option<String> = None;
378 let mut catalog_path: Option<String> = None;
379
380 while let Some(arg) = args.next() {
381 match arg.as_str() {
382 "--install" => wasm_path = Some(next_val(&mut args, "--install")?),
383 "--id" => id = Some(next_val(&mut args, "--id")?),
384 "--name" => name = Some(next_val(&mut args, "--name")?),
385 "--desc" => desc = Some(next_val(&mut args, "--desc")?),
386 "--grant" => grant_file = Some(next_val(&mut args, "--grant")?),
387 "--icon" => icon = Some(next_val(&mut args, "--icon")?),
388 "--category" => category = Some(next_val(&mut args, "--category")?),
389 "--store" => store_dir = Some(next_val(&mut args, "--store")?),
390 "--apps-dir" => apps_dir = Some(next_val(&mut args, "--apps-dir")?),
391 "--catalog" => catalog_path = Some(next_val(&mut args, "--catalog")?),
392 otro if otro.starts_with("--") => {
393 return Err(format!("opción desconocida: {otro}"))
394 }
395 otro => wasm_path = Some(otro.to_string()),
396 }
397 }
398
399 let wasm_path = wasm_path.ok_or("--install requiere la ruta del .wasm")?;
400 let id = id.ok_or("--install requiere --id <id> (nombre del manifiesto)")?;
401 let wasm = std::fs::read(&wasm_path).map_err(|e| format!("leer {wasm_path}: {e}"))?;
402
403 let store_dir = store_dir.unwrap_or_else(cas_por_defecto);
404 let store = DiskStore::open(&store_dir).map_err(|e| format!("abrir CAS {store_dir}: {e}"))?;
405
406 // El bytecode entra al CAS direccionado por su hash (idéntico al que el
407 // modo hash pedirá para correrlo).
408 let bytecode = store.put(&wasm).map_err(|e| format!("guardar bytecode: {e}"))?;
409 let bytecode_hex = hash_to_hex(&bytecode);
410
411 // Si hay concesión, su blob entra también; el manifiesto la referencia por
412 // hash (descubrimiento de concesiones, no inline).
413 let grant_obj = match &grant_file {
414 Some(path) => {
415 let blob = std::fs::read(path).map_err(|e| format!("leer concesión {path}: {e}"))?;
416 let grant = ConcesionCapacidad::deserializar(&blob)
417 .map_err(|_| format!("la concesión {path} no deserializa"))?;
418 if grant.bytecode != bytecode {
419 return Err("la concesión es para otro bytecode".into());
420 }
421 let h = store
422 .put_grant(&grant)
423 .map_err(|e| format!("guardar concesión: {e}"))?;
424 Some(h)
425 }
426 None => None,
427 };
428 let grant_hex = grant_obj.as_ref().map(hash_to_hex);
429 // Con concesión declaramos MAX (efectivos = MAX & concedidos = concedidos);
430 // sin ella, app de sólo-UI.
431 let declarados = if grant_obj.is_some() { Permisos::MAX } else { 0 };
432
433 let label = name.unwrap_or_else(|| id.clone());
434 let entry = app_bus::AppEntry {
435 id: id.clone(),
436 label: label.clone(),
437 icon,
438 category: category.clone(),
439 launch: app_bus::Launch::Wasm {
440 bytecode_hex: bytecode_hex.clone(),
441 grant_hex: grant_hex.clone(),
442 },
443 handles: Vec::new(),
444 };
445 let toml = app_bus::entry_to_toml(&entry).map_err(|e| format!("serializar manifiesto: {e}"))?;
446
447 let apps_dir = match apps_dir {
448 Some(d) => std::path::PathBuf::from(d),
449 None => app_bus::apps_dir()
450 .ok_or("no se pudo ubicar el directorio de apps (~/.config/tawasuyu/apps)")?,
451 };
452 std::fs::create_dir_all(&apps_dir)
453 .map_err(|e| format!("crear {}: {e}", apps_dir.display()))?;
454 let manifest_path = apps_dir.join(format!("{id}.toml"));
455 std::fs::write(&manifest_path, &toml)
456 .map_err(|e| format!("escribir {}: {e}", manifest_path.display()))?;
457
458 // Si se pidió, publicar también en el catálogo buscable: el índice
459 // content-addressed que hace `--search`/`--run` posibles (y que viaja por la
460 // malla como un blob más).
461 let catalog_línea = match catalog_path {
462 Some(path) => {
463 let mut catalog = cargar_catalogo(&path)?;
464 catalog.upsert(CatalogEntry {
465 id: id.clone(),
466 name: label,
467 description: desc.unwrap_or_default(),
468 category,
469 bytecode,
470 declarados,
471 concesion: grant_obj,
472 });
473 if let Some(parent) = std::path::Path::new(&path).parent() {
474 std::fs::create_dir_all(parent)
475 .map_err(|e| format!("crear {}: {e}", parent.display()))?;
476 }
477 std::fs::write(&path, catalog.serializar())
478 .map_err(|e| format!("escribir catálogo {path}: {e}"))?;
479 format!("\n catálogo: {path} ({} apps)", catalog.entries.len())
480 }
481 None => String::new(),
482 };
483
484 let grant_línea = grant_hex
485 .map(|h| format!("\n concesión: {h}"))
486 .unwrap_or_default();
487 Ok(format!(
488 "instalada «{id}» en {}\n bytecode: {bytecode_hex}{grant_línea}\n CAS: {store_dir}{catalog_línea}",
489 manifest_path.display()
490 ))
491}
492
493/// El CAS de blobs por defecto del escritorio: `$XDG_CACHE_HOME/llimphi/blobs`
494/// (o `~/.cache/llimphi/blobs`). Es la convención que el chasis (dock,
495/// spotlight, mirada) asume al spawnearnos con sólo `--hash`.
496fn cas_por_defecto() -> String {
497 let base = std::env::var("XDG_CACHE_HOME")
498 .ok()
499 .filter(|s| !s.is_empty())
500 .or_else(|| std::env::var("HOME").ok().map(|h| format!("{h}/.cache")))
501 .unwrap_or_else(|| ".".to_string());
502 format!("{base}/llimphi/blobs")
503}
504
505/// El catálogo de apps por defecto del escritorio:
506/// `$XDG_CONFIG_HOME/tawasuyu/catalog.bin` (o `~/.config/tawasuyu/catalog.bin`).
507fn catalogo_por_defecto() -> String {
508 let base = std::env::var("XDG_CONFIG_HOME")
509 .ok()
510 .filter(|s| !s.is_empty())
511 .or_else(|| std::env::var("HOME").ok().map(|h| format!("{h}/.config")))
512 .unwrap_or_else(|| ".".to_string());
513 format!("{base}/tawasuyu/catalog.bin")
514}
515
516/// Carga el catálogo del archivo (o el de por defecto). Un archivo ausente es un
517/// catálogo vacío (todavía no se publicó nada); uno corrupto es error.
518fn cargar_catalogo(path: &str) -> Result<Catalog, String> {
519 match std::fs::read(path) {
520 Ok(bytes) => Catalog::deserializar(&bytes)
521 .map_err(|_| format!("el catálogo {path} está corrupto")),
522 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Catalog::default()),
523 Err(e) => Err(format!("leer catálogo {path}: {e}")),
524 }
525}
526
527/// Modo búsqueda: lista o filtra las apps del catálogo por texto. Devuelve el
528/// listado formateado (id, nombre, categoría, hash corto, marca de permisos).
529fn buscar(args: impl Iterator<Item = String>) -> Result<String, String> {
530 let mut args = args.peekable();
531 let mut query: Option<String> = None;
532 let mut catalog_path: Option<String> = None;
533 while let Some(arg) = args.next() {
534 match arg.as_str() {
535 "--search" => query = Some(next_val(&mut args, "--search")?),
536 "--list" => {}
537 "--catalog" => catalog_path = Some(next_val(&mut args, "--catalog")?),
538 otro if otro.starts_with("--") => {
539 return Err(format!("opción desconocida: {otro}"))
540 }
541 otro => query = Some(otro.to_string()),
542 }
543 }
544 let path = catalog_path.unwrap_or_else(catalogo_por_defecto);
545 let catalog = cargar_catalogo(&path)?;
546 let q = query.unwrap_or_default();
547 let hits = catalog.search(&q);
548 if hits.is_empty() {
549 return Ok(if catalog.entries.is_empty() {
550 format!("catálogo vacío ({path}) — publica apps con --install --catalog")
551 } else {
552 format!("sin coincidencias para «{q}» en {path}")
553 });
554 }
555 let mut out = format!("{} app(s) en {path}:\n", hits.len());
556 for e in hits {
557 let cat = e.category.as_deref().unwrap_or("—");
558 let permisos = if e.concesion.is_some() { " ⚷" } else { "" };
559 out.push_str(&format!(
560 " {:<14} {:<22} [{}] {}{}\n",
561 e.id,
562 e.name,
563 cat,
564 &hash_to_hex(&e.bytecode)[..12],
565 permisos,
566 ));
567 }
568 Ok(out.trim_end().to_string())
569}
570
571/// Modo registro: conecta a un registro de apps (REST en RON, o un directorio
572/// local) y lista/busca; con `--ingest`, descarga los módulos que coincidan al
573/// CAS y los suma al catálogo local (para luego `--run`). El transporte se elige
574/// por la instancia: `http(s)://…` ⇒ red real; cualquier otra cosa ⇒ filesystem.
575fn registro(args: impl Iterator<Item = String>) -> Result<String, String> {
576 use llimphi_wasm_registry::{LocalFetch, RegistryDescriptor, RegistryProvider, UreqFetch};
577
578 let mut args = args.peekable();
579 let mut descriptor_path: Option<String> = None;
580 let mut instance: Option<String> = None;
581 let mut query: Option<String> = None;
582 let mut ingest = false;
583 let mut catalog_path: Option<String> = None;
584 let mut store_dir: Option<String> = None;
585 while let Some(arg) = args.next() {
586 match arg.as_str() {
587 "--registry" => descriptor_path = Some(next_val(&mut args, "--registry")?),
588 "--instance" => instance = Some(next_val(&mut args, "--instance")?),
589 "--query" | "--search" => query = Some(next_val(&mut args, "--query")?),
590 "--ingest" => ingest = true,
591 "--catalog" => catalog_path = Some(next_val(&mut args, "--catalog")?),
592 "--store" => store_dir = Some(next_val(&mut args, "--store")?),
593 otro if otro.starts_with("--") => {
594 return Err(format!("opción desconocida: {otro}"))
595 }
596 _ => {}
597 }
598 }
599
600 let descriptor_path = descriptor_path.ok_or("--registry requiere el archivo .ron")?;
601 let ron = std::fs::read_to_string(&descriptor_path)
602 .map_err(|e| format!("leer descriptor {descriptor_path}: {e}"))?;
603 let descriptor =
604 RegistryDescriptor::from_ron(&ron).map_err(|e| format!("descriptor: {e}"))?;
605 let base = instance.unwrap_or_default();
606 let es_http = base.starts_with("http://") || base.starts_with("https://");
607 let q = query.unwrap_or_default();
608
609 // El listado y la ingesta son idénticos sea cual sea el transporte; sólo
610 // cambia el fetcher. Una pequeña función genérica evita duplicar la lógica.
611 fn operar<F: llimphi_wasm_registry::HttpFetch>(
612 provider: RegistryProvider<F>,
613 q: &str,
614 ingest: bool,
615 catalog_path: Option<String>,
616 store_dir: Option<String>,
617 ) -> Result<String, String> {
618 let apps = provider.list(q).map_err(|e| format!("listar: {e}"))?;
619 let hits: Vec<_> = apps.into_iter().filter(|a| a.matches(q)).collect();
620 if hits.is_empty() {
621 return Ok(format!("sin apps para «{q}» en el registro"));
622 }
623 if !ingest {
624 let mut out = format!("{} app(s) en el registro:\n", hits.len());
625 for a in &hits {
626 let cat = a.category.as_deref().unwrap_or("—");
627 out.push_str(&format!(" {:<16} {:<24} [{}] {}\n", a.id, a.name, cat, a.wasm_url));
628 }
629 out.push_str(" (usa --ingest para bajarlas al catálogo local)");
630 return Ok(out);
631 }
632 // Ingesta: descargar cada módulo al CAS y sumarlo al catálogo local.
633 let store_dir = store_dir.unwrap_or_else(cas_por_defecto);
634 let store = DiskStore::open(&store_dir).map_err(|e| format!("abrir CAS {store_dir}: {e}"))?;
635 let cat_path = catalog_path.unwrap_or_else(catalogo_por_defecto);
636 let mut catalog = cargar_catalogo(&cat_path)?;
637 let mut n = 0;
638 for a in &hits {
639 let entry = provider
640 .ingest(a, &store)
641 .map_err(|e| format!("ingerir «{}»: {e}", a.id))?;
642 catalog.upsert(entry);
643 n += 1;
644 }
645 if let Some(parent) = std::path::Path::new(&cat_path).parent() {
646 std::fs::create_dir_all(parent).map_err(|e| format!("crear {}: {e}", parent.display()))?;
647 }
648 std::fs::write(&cat_path, catalog.serializar())
649 .map_err(|e| format!("escribir catálogo {cat_path}: {e}"))?;
650 Ok(format!(
651 "ingeridas {n} app(s) al catálogo {cat_path} ({} en total)\n CAS: {store_dir}",
652 catalog.entries.len()
653 ))
654 }
655
656 if es_http {
657 operar(RegistryProvider::<UreqFetch>::new(descriptor, base), &q, ingest, catalog_path, store_dir)
658 } else {
659 // Sin instancia http: tratamos `base` (o el dir del descriptor) como un
660 // registro local en disco.
661 let root = if base.is_empty() {
662 std::path::Path::new(&descriptor_path)
663 .parent()
664 .map(|p| p.to_string_lossy().to_string())
665 .unwrap_or_else(|| ".".into())
666 } else {
667 base.clone()
668 };
669 let provider = RegistryProvider::with_fetch(descriptor, root.clone(), LocalFetch::new(&root));
670 operar(provider, &q, ingest, catalog_path, store_dir)
671 }
672}
673
674/// Modo correr-por-id: resuelve una app del catálogo por su `id` (trae bytecode
675/// + concesión del CAS, verifica) y produce el [`LaunchSpec`] para abrirla.
676fn spec_desde_catalogo(args: impl Iterator<Item = String>) -> Result<LaunchSpec, String> {
677 let mut args = args.peekable();
678 let mut id: Option<String> = None;
679 let mut catalog_path: Option<String> = None;
680 let mut store_dir: Option<String> = None;
681 let mut ring_path: Option<String> = None;
682 while let Some(arg) = args.next() {
683 match arg.as_str() {
684 "--run" => id = Some(next_val(&mut args, "--run")?),
685 "--catalog" => catalog_path = Some(next_val(&mut args, "--catalog")?),
686 "--store" => store_dir = Some(next_val(&mut args, "--store")?),
687 "--ring" => ring_path = Some(next_val(&mut args, "--ring")?),
688 "--here" => {}
689 otro if otro.starts_with("--") => {
690 return Err(format!("opción desconocida: {otro}"))
691 }
692 otro => id = Some(otro.to_string()),
693 }
694 }
695 let id = id.ok_or("--run requiere el id de la app")?;
696 let path = catalog_path.unwrap_or_else(catalogo_por_defecto);
697 let catalog = cargar_catalogo(&path)?;
698 let entry = catalog
699 .get(&id)
700 .ok_or_else(|| format!("«{id}» no está en el catálogo {path}"))?;
701 let title = entry.name.clone();
702
703 let store_dir = store_dir.unwrap_or_else(cas_por_defecto);
704 let store = DiskStore::open(&store_dir).map_err(|e| format!("abrir CAS {store_dir}: {e}"))?;
705 let trust = match &ring_path {
706 Some(p) => TrustRing::load(p).map_err(|e| format!("anillo {p}: {e}"))?,
707 None => TrustRing::empty(),
708 };
709 let app = resolve_from_catalog(&store, &trust, &catalog, &id)
710 .map_err(|e| format!("resolver «{id}»: {e}"))?;
711 Ok(LaunchSpec {
712 wasm: app.wasm,
713 permisos: app.permisos,
714 title,
715 })
716}
717
718/// Los primeros 8 caracteres del hex, para un título legible.
719fn short_hex(hex: &str) -> String {
720 format!("wasm:{}", &hex[..hex.len().min(8)])
721}
722
723/// El nombre de archivo sin directorio ni extensión, para el título.
724fn nombre_archivo(path: &str) -> String {
725 std::path::Path::new(path)
726 .file_stem()
727 .and_then(|s| s.to_str())
728 .unwrap_or("wasm")
729 .to_string()
730}
731
732#[cfg(test)]
733mod tests {
734 use super::*;
735 use llimphi_wasm_dist::{hash_to_hex, DiskStore};
736
737 /// El counter Tier 3 ya compilado y versionado por build-wasm-demo.sh.
738 const COUNTER_WASM: &[u8] =
739 include_bytes!("../../llimphi-wasm-runner/assets/counter.wasm");
740
741 fn args(v: &[&str]) -> impl Iterator<Item = String> {
742 v.iter().map(|s| s.to_string()).collect::<Vec<_>>().into_iter()
743 }
744
745 /// Un CAS temporal único para este proceso de test.
746 fn cas_temporal(sufijo: &str) -> std::path::PathBuf {
747 let dir = std::env::temp_dir()
748 .join(format!("llimphi-wasm-open-{}-{sufijo}", std::process::id()));
749 let _ = std::fs::remove_dir_all(&dir);
750 std::fs::create_dir_all(&dir).unwrap();
751 dir
752 }
753
754 #[test]
755 fn modo_hash_resuelve_desde_el_cas() {
756 let dir = cas_temporal("hash");
757 let store = DiskStore::open(&dir).unwrap();
758 let hash = store.put(COUNTER_WASM).unwrap();
759 let hex = hash_to_hex(&hash);
760
761 let spec = resolver_spec(args(&[
762 "--hash",
763 &hex,
764 "--store",
765 dir.to_str().unwrap(),
766 ]))
767 .expect("resolver por hash");
768
769 // Sin concesión ⇒ sólo-UI (fail-closed) y los bytes son los del counter.
770 assert_eq!(spec.permisos, 0);
771 assert_eq!(spec.wasm, COUNTER_WASM);
772 // El guest verificado realmente instancia.
773 WasmGuest::load(&spec.wasm, spec.permisos).expect("instanciar counter");
774
775 std::fs::remove_dir_all(&dir).ok();
776 }
777
778 #[test]
779 fn hash_que_no_esta_en_el_cas_falla() {
780 let dir = cas_temporal("ausente");
781 // Un hash válido en forma pero no presente en el CAS.
782 let hex = "00".repeat(32);
783 let err = resolver_spec(args(&["--hash", &hex, "--store", dir.to_str().unwrap()]))
784 .unwrap_err();
785 assert!(err.contains("resolver app"), "mensaje real: {err}");
786 std::fs::remove_dir_all(&dir).ok();
787 }
788
789 #[test]
790 fn modo_local_carga_un_wasm_de_archivo() {
791 let dir = cas_temporal("local");
792 let path = dir.join("counter.wasm");
793 std::fs::write(&path, COUNTER_WASM).unwrap();
794
795 let spec =
796 resolver_spec(args(&[path.to_str().unwrap()])).expect("cargar .wasm local");
797 assert_eq!(spec.permisos, 0);
798 assert_eq!(spec.wasm, COUNTER_WASM);
799 assert_eq!(spec.title, "counter");
800 std::fs::remove_dir_all(&dir).ok();
801 }
802
803 #[test]
804 fn cas_por_defecto_honra_xdg_cache_home() {
805 // Camino que el chasis asume al spawnearnos con sólo el hash.
806 let prev = std::env::var("XDG_CACHE_HOME").ok();
807 std::env::set_var("XDG_CACHE_HOME", "/tmp/xdgtest");
808 assert_eq!(cas_por_defecto(), "/tmp/xdgtest/llimphi/blobs");
809 match prev {
810 Some(v) => std::env::set_var("XDG_CACHE_HOME", v),
811 None => std::env::remove_var("XDG_CACHE_HOME"),
812 }
813 }
814
815 #[test]
816 fn opcion_desconocida_es_error() {
817 let err = resolver_spec(args(&["--vuela"])).unwrap_err();
818 assert!(err.contains("desconocida"), "mensaje real: {err}");
819 }
820
821 #[test]
822 fn install_publica_y_se_puede_lanzar() {
823 let dir = cas_temporal("install");
824 let cas = dir.join("blobs");
825 let apps = dir.join("apps");
826 let wasm_path = dir.join("counter.wasm");
827 std::fs::write(&wasm_path, COUNTER_WASM).unwrap();
828
829 // Lado productor: publica la app (blob al CAS + manifiesto).
830 let resumen = instalar(args(&[
831 "--install",
832 wasm_path.to_str().unwrap(),
833 "--id",
834 "counter",
835 "--name",
836 "Counter",
837 "--store",
838 cas.to_str().unwrap(),
839 "--apps-dir",
840 apps.to_str().unwrap(),
841 ]))
842 .expect("instalar");
843 assert!(resumen.contains("instalada «counter»"), "resumen: {resumen}");
844
845 // El manifiesto existe y el registro lo descubre como un Launch::Wasm.
846 let toml = std::fs::read_to_string(apps.join("counter.toml")).unwrap();
847 let parsed = app_bus::parse_entry(&toml).expect("re-parsea el manifiesto");
848 assert_eq!(parsed.id, "counter");
849 assert_eq!(parsed.label, "Counter");
850 let bytecode_hex = match &parsed.launch {
851 app_bus::Launch::Wasm { bytecode_hex, grant_hex } => {
852 assert!(grant_hex.is_none(), "sin concesión");
853 bytecode_hex.clone()
854 }
855 otro => panic!("se esperaba Launch::Wasm, vino {otro:?}"),
856 };
857
858 // Lado consumidor: el mismo hash resuelve desde el CAS y carga.
859 let spec = resolver_spec(args(&[
860 "--hash",
861 &bytecode_hex,
862 "--store",
863 cas.to_str().unwrap(),
864 ]))
865 .expect("lanzar lo instalado");
866 assert_eq!(spec.wasm, COUNTER_WASM);
867 WasmGuest::load(&spec.wasm, spec.permisos).expect("instanciar counter instalado");
868
869 std::fs::remove_dir_all(&dir).ok();
870 }
871
872 #[test]
873 fn catalogo_publica_busca_y_corre_por_id() {
874 let dir = cas_temporal("catalogo");
875 let cas = dir.join("blobs");
876 let apps = dir.join("apps");
877 let catalogo = dir.join("catalog.bin");
878 let wasm_path = dir.join("counter.wasm");
879 std::fs::write(&wasm_path, COUNTER_WASM).unwrap();
880
881 // Publicar al catálogo (--catalog).
882 let resumen = instalar(args(&[
883 "--install",
884 wasm_path.to_str().unwrap(),
885 "--id",
886 "counter",
887 "--name",
888 "Contador",
889 "--desc",
890 "suma y resta un número",
891 "--category",
892 "demo",
893 "--store",
894 cas.to_str().unwrap(),
895 "--apps-dir",
896 apps.to_str().unwrap(),
897 "--catalog",
898 catalogo.to_str().unwrap(),
899 ]))
900 .expect("instalar+publicar");
901 assert!(resumen.contains("catálogo"), "resumen: {resumen}");
902
903 // Buscar por texto encuentra la app.
904 let listado = buscar(args(&[
905 "--search",
906 "resta",
907 "--catalog",
908 catalogo.to_str().unwrap(),
909 ]))
910 .expect("buscar");
911 assert!(listado.contains("counter"), "listado: {listado}");
912 assert!(listado.contains("Contador"), "listado: {listado}");
913
914 // Una búsqueda que no matchea.
915 let vacio = buscar(args(&["--search", "zzz", "--catalog", catalogo.to_str().unwrap()]))
916 .expect("buscar vacío");
917 assert!(vacio.contains("sin coincidencias"), "vacío: {vacio}");
918
919 // Correr por id resuelve desde el catálogo + CAS y produce el spec.
920 let spec = spec_desde_catalogo(args(&[
921 "--run",
922 "counter",
923 "--catalog",
924 catalogo.to_str().unwrap(),
925 "--store",
926 cas.to_str().unwrap(),
927 ]))
928 .expect("correr por id");
929 assert_eq!(spec.wasm, COUNTER_WASM);
930 assert_eq!(spec.title, "Contador");
931 WasmGuest::load(&spec.wasm, spec.permisos).expect("instanciar lo elegido");
932
933 // Re-publicar (upsert) no duplica.
934 instalar(args(&[
935 "--install",
936 wasm_path.to_str().unwrap(),
937 "--id",
938 "counter",
939 "--name",
940 "Contador v2",
941 "--store",
942 cas.to_str().unwrap(),
943 "--apps-dir",
944 apps.to_str().unwrap(),
945 "--catalog",
946 catalogo.to_str().unwrap(),
947 ]))
948 .expect("re-publicar");
949 let cat = Catalog::deserializar(&std::fs::read(&catalogo).unwrap()).unwrap();
950 assert_eq!(cat.entries.len(), 1, "upsert por id, no duplica");
951 assert_eq!(cat.get("counter").unwrap().name, "Contador v2");
952
953 // Un id ausente del catálogo es error.
954 assert!(spec_desde_catalogo(args(&[
955 "--run",
956 "fantasma",
957 "--catalog",
958 catalogo.to_str().unwrap(),
959 ]))
960 .is_err());
961
962 std::fs::remove_dir_all(&dir).ok();
963 }
964
965 #[test]
966 fn comando_para_shuma_envuelve_la_invocacion() {
967 let cmd = comando_para_shuma(
968 "/usr/bin/llimphi-wasm-open",
969 &["--run".into(), "hello".into(), "--store".into(), "/cas".into()],
970 );
971 assert_eq!(
972 cmd,
973 vec![
974 "-e",
975 "/usr/bin/llimphi-wasm-open",
976 "--run",
977 "hello",
978 "--store",
979 "/cas",
980 "--here",
981 ]
982 );
983 // `--here` al final ⇒ el re-lanzamiento corre local (no re-delega).
984 assert_eq!(cmd.last().unwrap(), "--here");
985 }
986
987 #[test]
988 fn here_es_aceptado_por_los_parsers() {
989 // Que `--here` no rompa el parseo (lo agrega la delegación a shuma).
990 let dir = cas_temporal("here");
991 let store = DiskStore::open(&dir).unwrap();
992 let hash = store.put(COUNTER_WASM).unwrap();
993 let spec = resolver_spec(args(&[
994 "--hash",
995 &hash_to_hex(&hash),
996 "--store",
997 dir.to_str().unwrap(),
998 "--here",
999 ]))
1000 .expect("--here no rompe resolver_spec");
1001 assert_eq!(spec.wasm, COUNTER_WASM);
1002 std::fs::remove_dir_all(&dir).ok();
1003 }
1004
1005 #[test]
1006 fn app_wasi_se_resuelve_y_se_detecta_como_consola() {
1007 use llimphi_wasm_wasi::{detect_kind, run_console, WasmKind};
1008
1009 // Un módulo WASI mínimo (escribe "ok" y sale 0), compilado de WAT.
1010 let wasm = wat::parse_str(
1011 r#"(module
1012 (import "wasi_snapshot_preview1" "fd_write"
1013 (func $w (param i32 i32 i32 i32) (result i32)))
1014 (import "wasi_snapshot_preview1" "proc_exit" (func $e (param i32)))
1015 (memory (export "memory") 1)
1016 (data (i32.const 8) "ok")
1017 (func (export "_start")
1018 (i32.store (i32.const 0) (i32.const 8))
1019 (i32.store (i32.const 4) (i32.const 2))
1020 (drop (call $w (i32.const 1) (i32.const 0) (i32.const 1) (i32.const 20)))
1021 (call $e (i32.const 0))))"#,
1022 )
1023 .unwrap();
1024
1025 // Entra al CAS y se resuelve por hash como cualquier app.
1026 let dir = cas_temporal("wasi");
1027 let store = DiskStore::open(&dir).unwrap();
1028 let hash = store.put(&wasm).unwrap();
1029 let spec = resolver_spec(args(&[
1030 "--hash",
1031 &hash_to_hex(&hash),
1032 "--store",
1033 dir.to_str().unwrap(),
1034 ]))
1035 .expect("resolver WASI por hash");
1036
1037 // El launcher la reconocería como consola (no UI) y la correría.
1038 assert_eq!(detect_kind(&spec.wasm), WasmKind::WasiConsole);
1039 let out = run_console(&spec.wasm, &[], &[], &[]).unwrap();
1040 assert_eq!(out.stdout_text(), "ok");
1041 assert_eq!(out.exit_code, 0);
1042
1043 std::fs::remove_dir_all(&dir).ok();
1044 }
1045
1046 #[test]
1047 fn registro_local_lista_ingiere_y_queda_corrible() {
1048 // Registro local offline (un dir con apps.json + .wasm) → listar →
1049 // ingerir al catálogo → correr por id. Todo sin red.
1050 let dir = cas_temporal("registro");
1051 std::fs::write(dir.join("counter.wasm"), COUNTER_WASM).unwrap();
1052 std::fs::write(
1053 dir.join("apps.json"),
1054 r#"{"results":[{"slug":"counter","title":"Contador","summary":"demo offline","tags":["demo"],"download":{"wasm":"counter.wasm"}}]}"#,
1055 )
1056 .unwrap();
1057 let ron = dir.join("reg.ron");
1058 std::fs::write(
1059 &ron,
1060 "#![enable(implicit_some)]\n(name:\"local\",list:(path:\"/apps.json\",list_path:\"results\",fields:(id:\"slug\",name:\"title\",wasm_url:\"download.wasm\",description:\"summary\",category:\"tags.0\")))",
1061 )
1062 .unwrap();
1063 let cas = dir.join("blobs");
1064 let catalogo = dir.join("catalog.bin");
1065
1066 // Listar (sin --ingest) describe la app sin bajarla.
1067 let listado = registro(args(&[
1068 "--registry",
1069 ron.to_str().unwrap(),
1070 "--instance",
1071 dir.to_str().unwrap(),
1072 ]))
1073 .expect("listar registro");
1074 assert!(listado.contains("counter"), "listado: {listado}");
1075 assert!(!cas.exists(), "sin --ingest no baja nada");
1076
1077 // Ingerir baja al CAS y suma al catálogo local.
1078 let res = registro(args(&[
1079 "--registry",
1080 ron.to_str().unwrap(),
1081 "--instance",
1082 dir.to_str().unwrap(),
1083 "--ingest",
1084 "--store",
1085 cas.to_str().unwrap(),
1086 "--catalog",
1087 catalogo.to_str().unwrap(),
1088 ]))
1089 .expect("ingerir");
1090 assert!(res.contains("ingeridas 1"), "res: {res}");
1091
1092 // Y lo ingerido se corre por id desde el catálogo local.
1093 let spec = spec_desde_catalogo(args(&[
1094 "--run",
1095 "counter",
1096 "--catalog",
1097 catalogo.to_str().unwrap(),
1098 "--store",
1099 cas.to_str().unwrap(),
1100 ]))
1101 .expect("correr lo ingerido");
1102 assert_eq!(spec.wasm, COUNTER_WASM);
1103 WasmGuest::load(&spec.wasm, spec.permisos).expect("instanciar lo ingerido");
1104
1105 std::fs::remove_dir_all(&dir).ok();
1106 }
1107}