gioser

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 229TOML 101Markdown 82Shell 24052 Definitions

⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8 llimphi-wasm-open/src/main.rs Rust · 1107 lines · 42.8 KB History Who touched what raw

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · Sergio ·

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}