From 934971e5d4f7373b67cc9d89d6066d5c95639812 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Thu, 23 Jul 2026 01:02:30 +0200 Subject: [PATCH 01/42] Add PTY wrapper for the main process --- Cargo.lock | 429 ++++++++++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 3 + src/lib.rs | 1 + src/main.rs | 41 ++++- src/proxy.rs | 8 +- src/tui.rs | 375 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 845 insertions(+), 12 deletions(-) create mode 100644 src/tui.rs diff --git a/Cargo.lock b/Cargo.lock index f6277ae..4e6da80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anstream" version = "1.0.0" @@ -87,6 +93,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "autocfg" version = "1.5.1" @@ -122,10 +134,12 @@ dependencies = [ "base64", "clap", "colored", + "crossterm", "glob", "libc", "pbkdf2", "rand", + "ratatui", "rpassword", "serde", "serde_json", @@ -134,6 +148,7 @@ dependencies = [ "tempfile", "tokio", "tokio-vsock", + "vt100", ] [[package]] @@ -142,6 +157,21 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -206,6 +236,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -215,6 +259,31 @@ dependencies = [ "libc", ] +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -235,6 +304,40 @@ dependencies = [ "cipher", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -246,6 +349,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "equivalent" version = "1.0.2" @@ -268,6 +377,12 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "futures" version = "0.3.33" @@ -404,12 +519,29 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hmac" version = "0.12.1" @@ -419,6 +551,12 @@ dependencies = [ "digest", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "indexmap" version = "2.14.0" @@ -426,7 +564,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", ] [[package]] @@ -438,12 +585,34 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -456,12 +625,42 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "memchr" version = "2.8.3" @@ -484,6 +683,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -519,6 +719,35 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pbkdf2" version = "0.12.2" @@ -610,6 +839,36 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "rpassword" version = "7.5.4" @@ -631,6 +890,19 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -640,16 +912,28 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "ryu" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.228" @@ -717,6 +1001,27 @@ dependencies = [ "digest", ] +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -733,6 +1038,12 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + [[package]] name = "socket2" version = "0.6.5" @@ -743,12 +1054,40 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -775,7 +1114,7 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -831,6 +1170,35 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "universal-hash" version = "0.5.1" @@ -869,12 +1237,67 @@ dependencies = [ "nix", ] +[[package]] +name = "vt100" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84cd863bf0db7e392ba3bd04994be3473491b31e66340672af5d11943c6274de" +dependencies = [ + "itoa", + "log", + "unicode-width 0.1.14", + "vte", +] + +[[package]] +name = "vte" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197" +dependencies = [ + "arrayvec", + "utf8parse", + "vte_generate_state_changes", +] + +[[package]] +name = "vte_generate_state_changes" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index c40994a..b4d4e39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,9 @@ serde_yaml = "0.9" sha2 = "0.10" tokio = { version = "1", features = ["rt-multi-thread", "net", "macros", "sync", "io-util", "process"] } tokio-vsock = "0.7" +ratatui = "0.29" +crossterm = "0.28" +vt100 = "0.15" [dev-dependencies] tempfile = "3" diff --git a/src/lib.rs b/src/lib.rs index d63a0e9..782c28e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,5 +7,6 @@ pub mod kata; pub mod overlay; pub mod proxy; pub mod sandbox; +pub mod tui; pub mod vscomm; pub mod workspace; diff --git a/src/main.rs b/src/main.rs index 3065a90..f4260f3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ use bunkerbox::cfg::{ProjectConfig, WorkspaceMode}; -use bunkerbox::{cfg, cfgsetup, clidef, cmdrun, daemon, kata, overlay, workspace}; +use bunkerbox::{cfg, cfgsetup, clidef, cmdrun, daemon, kata, overlay, tui, workspace}; use std::ffi::OsString; +use std::os::unix::io::RawFd; use std::path::{Path, PathBuf}; fn main() { @@ -175,13 +176,47 @@ fn run_packaged_runtime(config: cfg::RuntimeConfig, workspace_override: Option 0, + Err(e) => { + eprintln!("bunkerbox: {e}"); + 1 + } + }; + std::process::exit(code); + } + + let tui_result = tui::event_loop(master, rows, cols); + + let mut status: i32 = 0; + unsafe { libc::waitpid(pid, &mut status, 0) }; + unsafe { libc::close(master) }; if let Some(d) = daemon { tokio::runtime::Handle::current().block_on(d.shutdown()); } - result + tui_result?; + + if status != 0 { + return Err(format!("child exited with status {status}")); + } + + Ok(()) } fn print_subcommand_help(name: &str) -> Result<(), String> { diff --git a/src/proxy.rs b/src/proxy.rs index 84d4b16..b56c28e 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -72,9 +72,7 @@ async fn handle_client(mut client: TcpStream, allow: &[String]) -> Result<(), St (h, p, true) } else if target.starts_with("http://") { let url = target.strip_prefix("http://").ok_or_else(|| format!("malformed URL: {target}"))?; - let (h, p) = url.split_once('/').map_or_else(|| (url, "80"), |(hp, _)| { - hp.split_once(':').unwrap_or((hp, "80")) - }); + let (h, p) = url.split_once('/').map_or_else(|| (url, "80"), |(hp, _)| hp.split_once(':').unwrap_or((hp, "80"))); (h.to_string(), p.to_string(), false) } else { return Err(format!("unsupported request: {first_line}")); @@ -87,9 +85,7 @@ async fn handle_client(mut client: TcpStream, allow: &[String]) -> Result<(), St } let upstream_addr = format!("{host}:{port}"); - let mut upstream = TcpStream::connect(&upstream_addr) - .await - .map_err(|e| format!("connect to {upstream_addr}: {e}"))?; + let mut upstream = TcpStream::connect(&upstream_addr).await.map_err(|e| format!("connect to {upstream_addr}: {e}"))?; if is_connect { let established = b"HTTP/1.1 200 Connection Established\r\n\r\n"; diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..36d26d7 --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,375 @@ +use std::io; +use std::os::fd::AsRawFd; +use std::os::unix::io::RawFd; +use std::sync::atomic::{AtomicBool, Ordering}; + +use crossterm::cursor; +use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; +use crossterm::ExecutableCommand; +use ratatui::backend::CrosstermBackend; +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, Clear}; +use ratatui::Terminal; + +// --------------------------------------------------------------------------- +// Terminal emulator wrapper around vt100::Parser +// --------------------------------------------------------------------------- + +static RESIZED: AtomicBool = AtomicBool::new(false); + +extern "C" fn handle_sigwinch(_: libc::c_int) { + RESIZED.store(true, Ordering::SeqCst); +} + +struct Term { + parser: vt100::Parser, + escape_state: EscapeState, + g0_dec_special_graphics: bool, + g1_dec_special_graphics: bool, + using_g1_charset: bool, +} + +#[derive(Clone, Copy)] +enum EscapeState { + Ground, + Escape, + CharsetSelect(u8), + Csi, + String, + StringEscape, +} + +impl Term { + fn new(rows: u16, cols: u16) -> Self { + Self { + parser: vt100::Parser::new(rows, cols, 0), + escape_state: EscapeState::Ground, + g0_dec_special_graphics: false, + g1_dec_special_graphics: false, + using_g1_charset: false, + } + } + + fn screen(&self) -> &vt100::Screen { + self.parser.screen() + } + + fn set_size(&mut self, rows: u16, cols: u16) { + self.parser.set_size(rows, cols); + } + + fn process(&mut self, bytes: &[u8]) { + let translated = self.translate_dec_special_graphics(bytes); + self.parser.process(&translated); + } + + fn active_dec_special_graphics(&self) -> bool { + if self.using_g1_charset { + self.g1_dec_special_graphics + } else { + self.g0_dec_special_graphics + } + } + + fn translate_dec_special_graphics(&mut self, bytes: &[u8]) -> Vec { + let mut translated = Vec::with_capacity(bytes.len()); + + for &byte in bytes { + match self.escape_state { + EscapeState::Ground => match byte { + 0x1b => { + self.escape_state = EscapeState::Escape; + } + 0x0e => { + self.using_g1_charset = true; + } + 0x0f => { + self.using_g1_charset = false; + } + 0x20..=0x7e if self.active_dec_special_graphics() => { + push_dec_special_graphic(&mut translated, byte); + } + _ => { + translated.push(byte); + } + }, + EscapeState::Escape => match byte { + b'(' | b')' => { + self.escape_state = EscapeState::CharsetSelect(byte); + } + b'[' => { + self.escape_state = EscapeState::Csi; + translated.push(0x1b); + translated.push(byte); + } + b']' | b'P' | b'^' | b'_' => { + self.escape_state = EscapeState::String; + translated.push(0x1b); + translated.push(byte); + } + _ => { + self.escape_state = EscapeState::Ground; + translated.push(0x1b); + translated.push(byte); + } + }, + EscapeState::CharsetSelect(charset) => { + match charset { + b'(' => self.g0_dec_special_graphics = byte == b'0', + b')' => self.g1_dec_special_graphics = byte == b'0', + _ => {} + } + self.escape_state = EscapeState::Ground; + } + EscapeState::Csi => { + if byte == b'f' { + translated.push(b'H'); + } else { + translated.push(byte); + } + if (0x40..=0x7e).contains(&byte) { + self.escape_state = EscapeState::Ground; + } + } + EscapeState::String => { + translated.push(byte); + match byte { + 0x07 => self.escape_state = EscapeState::Ground, + 0x1b => self.escape_state = EscapeState::StringEscape, + _ => {} + } + } + EscapeState::StringEscape => { + translated.push(byte); + self.escape_state = if byte == b'\\' { EscapeState::Ground } else { EscapeState::String }; + } + } + } + + translated + } +} + +fn push_dec_special_graphic(out: &mut Vec, byte: u8) { + let mapped = match byte { + b'`' => '◆', + b'a' => '▒', + b'b' => '␉', + b'c' => '␌', + b'd' => '␍', + b'e' => '␊', + b'f' => '°', + b'g' => '±', + b'h' => '␤', + b'i' => '␋', + b'j' => '┘', + b'k' => '┐', + b'l' => '┌', + b'm' => '└', + b'n' => '┼', + b'o' => '⎺', + b'p' => '⎻', + b'q' => '─', + b'r' => '⎼', + b's' => '⎽', + b't' => '├', + b'u' => '┤', + b'v' => '┴', + b'w' => '┬', + b'x' => '│', + b'y' => '≤', + b'z' => '≥', + b'{' => 'π', + b'|' => '≠', + b'}' => '£', + b'~' => '·', + _ => { + out.push(byte); + return; + } + }; + let mut buf = [0u8; 4]; + out.extend_from_slice(mapped.encode_utf8(&mut buf).as_bytes()); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +pub fn event_loop(master_fd: RawFd, rows: u16, cols: u16) -> Result<(), String> { + let stdin_fd = io::stdin().as_raw_fd(); + + let mut stdout = io::stdout(); + terminal::enable_raw_mode().map_err(|e| format!("raw mode: {e}"))?; + stdout.execute(EnterAlternateScreen).map_err(|e| format!("alt screen: {e}"))?; + stdout.execute(cursor::Show).map_err(|e| format!("cursor: {e}"))?; + + let mut term = Term::new(rows, cols); + + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend).map_err(|e| format!("terminal: {e}"))?; + + let mut pty_buf = [0u8; 4096]; + let mut stdin_buf = [0u8; 1]; + + let mut last_rows = rows; + let mut last_cols = cols; + + unsafe { + libc::signal(libc::SIGWINCH, handle_sigwinch as *const () as libc::sighandler_t); + } + + loop { + if RESIZED.swap(false, Ordering::SeqCst) { + if let Ok((new_cols, new_rows)) = terminal::size() { + if new_cols != last_cols || new_rows != last_rows { + last_cols = new_cols; + last_rows = new_rows; + term.set_size(new_rows, new_cols); + let ws = libc::winsize { ws_row: new_rows, ws_col: new_cols, ws_xpixel: 0, ws_ypixel: 0 }; + unsafe { + libc::ioctl(master_fd, libc::TIOCSWINSZ, &ws); + } + } + } + } + + let mut fds = + [libc::pollfd { fd: master_fd, events: libc::POLLIN, revents: 0 }, libc::pollfd { fd: stdin_fd, events: libc::POLLIN, revents: 0 }]; + + let ret = unsafe { libc::poll(fds.as_mut_ptr(), 2, 16) }; + + if ret == -1 { + let err = io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::EINTR) { + continue; + } + terminal.backend_mut().execute(LeaveAlternateScreen).ok(); + terminal::disable_raw_mode().ok(); + return Err(format!("poll: {err}")); + } + + if fds[0].revents & (libc::POLLIN | libc::POLLHUP) != 0 { + let n = unsafe { libc::read(master_fd, pty_buf.as_mut_ptr() as *mut libc::c_void, pty_buf.len()) }; + if n > 0 { + term.process(&pty_buf[..n as usize]); + } else { + break; + } + } + + if fds[1].revents & libc::POLLIN != 0 { + let n = unsafe { libc::read(stdin_fd, stdin_buf.as_mut_ptr() as *mut libc::c_void, 1usize) }; + if n > 0 { + unsafe { + libc::write(master_fd, stdin_buf.as_ptr() as *const libc::c_void, 1usize); + } + } + } + + terminal.draw(|f| render_frame(f, term.screen())).map_err(|e| format!("draw: {e}"))?; + } + + terminal.backend_mut().execute(LeaveAlternateScreen).ok(); + terminal::disable_raw_mode().ok(); + unsafe { + libc::signal(libc::SIGWINCH, libc::SIG_DFL); + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +fn to_ratatui_color(c: vt100::Color) -> Color { + match c { + vt100::Color::Default => Color::Reset, + vt100::Color::Idx(i) => Color::Indexed(i), + vt100::Color::Rgb(r, g, b) => Color::Rgb(r, g, b), + } +} + +fn render_frame(f: &mut Frame, screen: &vt100::Screen) { + let area = f.area(); + let (rows, cols) = screen.size(); + let max_rows = area.height.min(rows); + let max_cols = area.width.min(cols); + let buf = f.buffer_mut(); + + for row in 0..max_rows { + let mut col: u16 = 0; + while col < max_cols { + let x = area.x + col; + let y = area.y + row; + + if let Some(cell) = screen.cell(row, col) { + if cell.is_wide_continuation() { + col += 1; + continue; + } + + let mut style = Style::default().fg(to_ratatui_color(cell.fgcolor())).bg(to_ratatui_color(cell.bgcolor())); + + if cell.bold() { + style = style.add_modifier(Modifier::BOLD); + } + if cell.italic() { + style = style.add_modifier(Modifier::ITALIC); + } + if cell.underline() { + style = style.add_modifier(Modifier::UNDERLINED); + } + if cell.inverse() { + style = style.add_modifier(Modifier::REVERSED); + } + + let ch = cell.contents(); + let display: &str = if ch.is_empty() { " " } else { &ch }; + + if let Some(c) = buf.cell_mut((x, y)) { + c.set_symbol(display); + c.set_style(style); + } + + if cell.is_wide() { + if col + 1 < max_cols { + if let Some(c) = buf.cell_mut((x + 1, y)) { + c.set_symbol(" "); + c.set_style(style); + } + } + col += 2; + } else { + col += 1; + } + } else { + if let Some(c) = buf.cell_mut((x, y)) { + c.set_symbol(" "); + c.set_style(Style::default()); + } + col += 1; + } + } + } + + let win_w = 15u16; + let win_h = 3u16; + let win_x = area.width.saturating_sub(win_w + 4); + let win_y = 2; + let rect = Rect::new(win_x, win_y, win_w, win_h); + + f.render_widget(Clear, rect); + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(" Bunkerbox ") + .title_style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD)); + f.render_widget(block, rect); + + let (cursor_row, cursor_col) = screen.cursor_position(); + if cursor_row < max_rows && cursor_col < max_cols { + f.set_cursor_position((area.x + cursor_col, area.y + cursor_row)); + } +} From 39fcff2733efadd037494fb8d20e8d99e846882c Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Thu, 23 Jul 2026 01:02:58 +0200 Subject: [PATCH 02/42] Add unit test for proxy --- tests/test_proxy.rs | 53 ++++++++++----------------------------------- 1 file changed, 12 insertions(+), 41 deletions(-) diff --git a/tests/test_proxy.rs b/tests/test_proxy.rs index 7d1c423..829cee1 100644 --- a/tests/test_proxy.rs +++ b/tests/test_proxy.rs @@ -13,19 +13,11 @@ async fn proxy_allows_connect_to_allowed_host() { sock.write_all(&buf[..n]).await.unwrap(); }); - let (handle, proxy_port) = FilterProxy::new(vec!["localhost".into()]) - .bind_on(0) - .await - .unwrap(); + let (handle, proxy_port) = FilterProxy::new(vec!["localhost".into()]).bind_on(0).await.unwrap(); - let mut client = tokio::net::TcpStream::connect(format!("127.0.0.1:{proxy_port}")) - .await - .unwrap(); + let mut client = tokio::net::TcpStream::connect(format!("127.0.0.1:{proxy_port}")).await.unwrap(); - client - .write_all(format!("CONNECT localhost:{echo_port} HTTP/1.1\r\n\r\n").as_bytes()) - .await - .unwrap(); + client.write_all(format!("CONNECT localhost:{echo_port} HTTP/1.1\r\n\r\n").as_bytes()).await.unwrap(); let mut response = [0u8; 256]; let n = client.read(&mut response).await.unwrap(); @@ -42,19 +34,11 @@ async fn proxy_allows_connect_to_allowed_host() { #[tokio::test] async fn proxy_blocks_connect_to_denied_host() { - let (handle, proxy_port) = FilterProxy::new(vec!["only.this.host".into()]) - .bind_on(0) - .await - .unwrap(); + let (handle, proxy_port) = FilterProxy::new(vec!["only.this.host".into()]).bind_on(0).await.unwrap(); - let mut client = tokio::net::TcpStream::connect(format!("127.0.0.1:{proxy_port}")) - .await - .unwrap(); + let mut client = tokio::net::TcpStream::connect(format!("127.0.0.1:{proxy_port}")).await.unwrap(); - client - .write_all(b"CONNECT evil.com:443 HTTP/1.1\r\n\r\n") - .await - .unwrap(); + client.write_all(b"CONNECT evil.com:443 HTTP/1.1\r\n\r\n").await.unwrap(); let mut response = [0u8; 256]; let n = client.read(&mut response).await.unwrap(); @@ -70,27 +54,14 @@ async fn proxy_forwards_plain_http_to_allowed_host() { let srv_port = srv.local_addr().unwrap().port(); tokio::spawn(async move { let (mut sock, _) = srv.accept().await.unwrap(); - sock.write_all(b"HTTP/1.0 200 OK\r\nContent-Length: 5\r\n\r\nworld") - .await - .unwrap(); + sock.write_all(b"HTTP/1.0 200 OK\r\nContent-Length: 5\r\n\r\nworld").await.unwrap(); }); - let (handle, proxy_port) = FilterProxy::new(vec!["localhost".into()]) - .bind_on(0) - .await - .unwrap(); - - let mut client = tokio::net::TcpStream::connect(format!("127.0.0.1:{proxy_port}")) - .await - .unwrap(); - - client - .write_all( - format!("GET http://localhost:{srv_port}/items HTTP/1.1\r\nHost: localhost\r\n\r\n") - .as_bytes(), - ) - .await - .unwrap(); + let (handle, proxy_port) = FilterProxy::new(vec!["localhost".into()]).bind_on(0).await.unwrap(); + + let mut client = tokio::net::TcpStream::connect(format!("127.0.0.1:{proxy_port}")).await.unwrap(); + + client.write_all(format!("GET http://localhost:{srv_port}/items HTTP/1.1\r\nHost: localhost\r\n\r\n").as_bytes()).await.unwrap(); let mut response = Vec::new(); let mut buf = [0u8; 512]; From ecba7f74053ec4602ec6ea9a91f19148b5cc6a6d Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Thu, 23 Jul 2026 01:08:01 +0200 Subject: [PATCH 03/42] Start PTY right in front of the whole process --- src/main.rs | 59 +++++++++++++++++++++++++++++++++++++---------------- src/tui.rs | 38 ++++++++++++++++++++++++++++++---- 2 files changed, 75 insertions(+), 22 deletions(-) diff --git a/src/main.rs b/src/main.rs index f4260f3..40c6edd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,10 @@ use bunkerbox::cfg::{ProjectConfig, WorkspaceMode}; use bunkerbox::{cfg, cfgsetup, clidef, cmdrun, daemon, kata, overlay, tui, workspace}; +use std::cell::RefCell; use std::ffi::OsString; use std::os::unix::io::RawFd; use std::path::{Path, PathBuf}; +use std::rc::Rc; fn main() { if let Err(err) = run() { @@ -152,31 +154,27 @@ fn run_packaged_runtime(config: cfg::RuntimeConfig, workspace_override: Option = config.allow.clone().unwrap_or_default().into_iter().chain(env.image.allow.clone().unwrap_or_default()).collect(); - let daemon = if !env.project.passthrough.is_empty() { - Some(daemon::VsockDaemon::start( - env.project.passthrough.clone(), - env.project.env, - workspace_path, - env.profiles.clone(), - share_dir.to_path_buf(), - merged_allow, - )?) - } else { - None - }; + let passthrough = env.project.passthrough.clone(); + let env_mode = env.project.env; + let profiles = env.profiles.clone(); + let share_dir_owned = share_dir.to_path_buf(); + let daemon_holder = Rc::new(RefCell::new(None)); + let daemon_clone = daemon_holder.clone(); - let vsock_enabled = daemon.is_some(); + let mut pipe_fds = [-1i32, -1]; + unsafe { + libc::pipe(pipe_fds.as_mut_ptr()); + } + let (pipe_r, pipe_w) = (pipe_fds[0], pipe_fds[1]); let (cols, rows) = crossterm::terminal::size().map_err(|e| format!("terminal size: {e}"))?; @@ -190,6 +188,17 @@ fn run_packaged_runtime(config: cfg::RuntimeConfig, workspace_override: Option 0, Err(e) => { @@ -200,13 +209,27 @@ fn run_packaged_runtime(config: cfg::RuntimeConfig, workspace_override: Option| { + let wp = PathBuf::from(String::from_utf8_lossy(&path_bytes).into_owned()); + if !passthrough.is_empty() { + *daemon_clone.borrow_mut() = Some(daemon::VsockDaemon::start(passthrough, env_mode, wp, profiles, share_dir_owned, merged_allow)?); + } + Ok(()) + }), + ); let mut status: i32 = 0; unsafe { libc::waitpid(pid, &mut status, 0) }; unsafe { libc::close(master) }; - if let Some(d) = daemon { + if let Some(d) = daemon_holder.borrow_mut().take() { tokio::runtime::Handle::current().block_on(d.shutdown()); } diff --git a/src/tui.rs b/src/tui.rs index 36d26d7..f3a8fe0 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -196,7 +196,10 @@ fn push_dec_special_graphic(out: &mut Vec, byte: u8) { // Public API // --------------------------------------------------------------------------- -pub fn event_loop(master_fd: RawFd, rows: u16, cols: u16) -> Result<(), String> { +pub fn event_loop(master_fd: RawFd, rows: u16, cols: u16, setup_fd: Option, on_setup: Option) -> Result<(), String> +where + F: FnOnce(Vec) -> Result<(), String>, +{ let stdin_fd = io::stdin().as_raw_fd(); let mut stdout = io::stdout(); @@ -215,6 +218,9 @@ pub fn event_loop(master_fd: RawFd, rows: u16, cols: u16) -> Result<(), String> let mut last_rows = rows; let mut last_cols = cols; + let mut setup_fd = setup_fd; + let mut on_setup = on_setup; + unsafe { libc::signal(libc::SIGWINCH, handle_sigwinch as *const () as libc::sighandler_t); } @@ -234,10 +240,14 @@ pub fn event_loop(master_fd: RawFd, rows: u16, cols: u16) -> Result<(), String> } } - let mut fds = - [libc::pollfd { fd: master_fd, events: libc::POLLIN, revents: 0 }, libc::pollfd { fd: stdin_fd, events: libc::POLLIN, revents: 0 }]; + let nfds: libc::c_ulong = if setup_fd.is_some() { 3 } else { 2 }; + let mut fds = [ + libc::pollfd { fd: master_fd, events: libc::POLLIN, revents: 0 }, + libc::pollfd { fd: stdin_fd, events: libc::POLLIN, revents: 0 }, + libc::pollfd { fd: setup_fd.unwrap_or(-1), events: libc::POLLIN, revents: 0 }, + ]; - let ret = unsafe { libc::poll(fds.as_mut_ptr(), 2, 16) }; + let ret = unsafe { libc::poll(fds.as_mut_ptr(), nfds, 16) }; if ret == -1 { let err = io::Error::last_os_error(); @@ -267,6 +277,26 @@ pub fn event_loop(master_fd: RawFd, rows: u16, cols: u16) -> Result<(), String> } } + if let Some(fd) = setup_fd { + if fds[2].revents & (libc::POLLIN | libc::POLLHUP) != 0 { + let mut buf = Vec::new(); + let mut chunk = [0u8; 256]; + loop { + let n = unsafe { libc::read(fd, chunk.as_mut_ptr() as *mut libc::c_void, chunk.len()) }; + if n > 0 { + buf.extend_from_slice(&chunk[..n as usize]); + } else { + break; + } + } + unsafe { libc::close(fd) }; + setup_fd = None; + if let Some(cb) = on_setup.take() { + cb(buf)?; + } + } + } + terminal.draw(|f| render_frame(f, term.screen())).map_err(|e| format!("draw: {e}"))?; } From 28eee5bc5fdbebb87a9c708c2476f56c956cec1d Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Thu, 23 Jul 2026 01:11:14 +0200 Subject: [PATCH 04/42] Fix docstrings --- src/tui.rs | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/src/tui.rs b/src/tui.rs index f3a8fe0..09c301f 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -11,16 +11,14 @@ use ratatui::prelude::*; use ratatui::widgets::{Block, Borders, Clear}; use ratatui::Terminal; -// --------------------------------------------------------------------------- -// Terminal emulator wrapper around vt100::Parser -// --------------------------------------------------------------------------- - static RESIZED: AtomicBool = AtomicBool::new(false); extern "C" fn handle_sigwinch(_: libc::c_int) { RESIZED.store(true, Ordering::SeqCst); } +/// Terminal emulator wrapper around [`vt100::Parser`] with DEC Special Graphics +/// character set translation and HVP-to-CUP normalization. struct Term { parser: vt100::Parser, escape_state: EscapeState, @@ -40,6 +38,7 @@ enum EscapeState { } impl Term { + /// Creates a new terminal of the given rows and columns. fn new(rows: u16, cols: u16) -> Self { Self { parser: vt100::Parser::new(rows, cols, 0), @@ -50,14 +49,17 @@ impl Term { } } + /// Returns a reference to the vt100 screen grid for rendering. fn screen(&self) -> &vt100::Screen { self.parser.screen() } + /// Resizes the terminal grid after a window resize event. fn set_size(&mut self, rows: u16, cols: u16) { self.parser.set_size(rows, cols); } + /// Feeds raw bytes through DEC translation then into the vt100 parser. fn process(&mut self, bytes: &[u8]) { let translated = self.translate_dec_special_graphics(bytes); self.parser.process(&translated); @@ -71,6 +73,8 @@ impl Term { } } + /// Translates `\e(0` DEC Special Graphics characters to Unicode + /// box-drawing glyphs and normalizes HVP (`CSI … f`) to CUP (`CSI … H`). fn translate_dec_special_graphics(&mut self, bytes: &[u8]) -> Vec { let mut translated = Vec::with_capacity(bytes.len()); @@ -150,6 +154,8 @@ impl Term { } } +/// Converts a single byte from the DEC Special Graphics table to its Unicode +/// equivalent (e.g. `x` → `│`, `q` → `─`). Appends the UTF-8 bytes to `out`. fn push_dec_special_graphic(out: &mut Vec, byte: u8) { let mapped = match byte { b'`' => '◆', @@ -192,10 +198,15 @@ fn push_dec_special_graphic(out: &mut Vec, byte: u8) { out.extend_from_slice(mapped.encode_utf8(&mut buf).as_bytes()); } -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - +/// Renders PTY output through ratatui until the child process exits. +/// +/// Forwards real keystrokes to the PTY master, parses terminal output through +/// [`vt100::Parser`], and draws each frame with full 24-bit color plus a +/// floating "Bunkerbox" overlay in the top-right corner. +/// +/// If `setup_fd` is provided, the loop also polls it for a single message; +/// when readable, all bytes are read, the fd is closed, and `on_setup` is +/// called once with the received data before polling continues. pub fn event_loop(master_fd: RawFd, rows: u16, cols: u16, setup_fd: Option, on_setup: Option) -> Result<(), String> where F: FnOnce(Vec) -> Result<(), String>, @@ -309,10 +320,8 @@ where Ok(()) } -// --------------------------------------------------------------------------- -// Rendering -// --------------------------------------------------------------------------- - +/// Converts a [`vt100::Color`] to a [`ratatui::style::Color`], preserving +/// 24-bit RGB, 256-color indexed palette, and terminal default. fn to_ratatui_color(c: vt100::Color) -> Color { match c { vt100::Color::Default => Color::Reset, @@ -321,6 +330,9 @@ fn to_ratatui_color(c: vt100::Color) -> Color { } } +/// Renders one frame: writes every vt100 screen cell to the ratatui buffer +/// with full color and attributes, draws the floating "Bunkerbox" overlay, +/// and positions the cursor. fn render_frame(f: &mut Frame, screen: &vt100::Screen) { let area = f.area(); let (rows, cols) = screen.size(); From 6f54a4156e81972c4a338f846751de12e9c33de3 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Thu, 23 Jul 2026 01:16:36 +0200 Subject: [PATCH 05/42] Update main readme badges --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cbb6f41..82cc335 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,11 @@ # Bunkerbox -[![CI](https://github.com/anomalyco/bunkerbox/actions/workflows/ci.yml/badge.svg)](https://github.com/anomalyco/bunkerbox/actions/workflows/ci.yml) +[![CI](https://github.com/tinythings/bunkerbox/actions/workflows/ci.yml/badge.svg)](https://github.com/tinythings/bunkerbox/actions/workflows/ci.yml) [![Rust](https://img.shields.io/badge/rust-1.97.1-orange)](https://www.rust-lang.org) -[![License](https://img.shields.io/github/license/anomalyco/bunkerbox)](https://github.com/anomalyco/bunkerbox/blob/main/LICENSE) +[![License](https://img.shields.io/github/license/tinythings/bunkerbox)](https://github.com/tinythings/bunkerbox/blob/main/LICENSE) +[![Docs](https://github.com/tinythings/bunkerbox/actions/workflows/docs.yml/badge.svg)](https://github.com/tinythings/bunkerbox/actions/workflows/docs.yml) +[![Release](https://img.shields.io/github/v/release/tinythings/bunkerbox)](https://github.com/tinythings/bunkerbox/releases) **Run powerful development agents without handing them your whole machine.** From d8cffc3616c6867f7390f05ba0eeb0f720771364 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Thu, 23 Jul 2026 01:18:44 +0200 Subject: [PATCH 06/42] Just looks nicer :-) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 82cc335..5fcef15 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ [![CI](https://github.com/tinythings/bunkerbox/actions/workflows/ci.yml/badge.svg)](https://github.com/tinythings/bunkerbox/actions/workflows/ci.yml) [![Rust](https://img.shields.io/badge/rust-1.97.1-orange)](https://www.rust-lang.org) [![License](https://img.shields.io/github/license/tinythings/bunkerbox)](https://github.com/tinythings/bunkerbox/blob/main/LICENSE) -[![Docs](https://github.com/tinythings/bunkerbox/actions/workflows/docs.yml/badge.svg)](https://github.com/tinythings/bunkerbox/actions/workflows/docs.yml) [![Release](https://img.shields.io/github/v/release/tinythings/bunkerbox)](https://github.com/tinythings/bunkerbox/releases) +[![Docs](https://github.com/tinythings/bunkerbox/actions/workflows/docs.yml/badge.svg)](https://github.com/tinythings/bunkerbox/actions/workflows/docs.yml) **Run powerful development agents without handing them your whole machine.** From 4854473c21e204ecf486f7a029854ea1912cddee Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Mon, 27 Jul 2026 17:08:49 +0200 Subject: [PATCH 07/42] Add vscomm-based TUI proto for wrapping behaviour --- src/kata.rs | 27 ++++++- src/main.rs | 88 ++++++++++++++++++---- src/tui.rs | 188 +++++++++++++++++++++++++++++++++++----------- src/vscomm/mod.rs | 22 ++++++ 4 files changed, 268 insertions(+), 57 deletions(-) diff --git a/src/kata.rs b/src/kata.rs index bdbd4a0..eb67f57 100644 --- a/src/kata.rs +++ b/src/kata.rs @@ -11,6 +11,7 @@ use std::collections::BTreeSet; use std::fs; use std::io::{BufRead, BufReader}; use std::net::{IpAddr, ToSocketAddrs}; +use std::os::unix::io::RawFd; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::thread; @@ -29,7 +30,14 @@ const BRIDGE_NAME: &str = "bunkerbox0"; /// `Ok(())` on successful container execution, `Err(String)` on failure. pub fn run( config: &RuntimeConfig, workspace: WorkspaceHandle, container_name: &str, _share_dir: &Path, app_name: &str, vsock_enabled: bool, + status_fd: RawFd, ) -> Result<(), String> { + fn write_status(fd: RawFd, msg: &str) { + let mut buf = msg.as_bytes().to_vec(); + buf.push(b'\n'); + unsafe { libc::write(fd, buf.as_ptr() as *const libc::c_void, buf.len()); } + } + if !config.oci.is_file() { return Err(format!("OCI archive not found: {}", config.oci.display())); } @@ -38,6 +46,8 @@ pub fn run( let home_path = (config.home == Some(HomeMode::Persist)).then(|| config.home_path.clone().unwrap_or_else(|| default_home_path(app_name))); + write_status(status_fd, "Preparing runtime..."); + let needs_bridge = config.network == Some(NetworkMode::Bridge); let ctr_name = container_name.to_string(); let oci_path = config.oci.to_string_lossy().into_owned(); @@ -51,6 +61,8 @@ pub fn run( run_command_allow_failure("sudo", &["mkdir", "-p", &format!("{}", runtime_dir.display())])?; run_command_quiet("sudo", &["chown", &user, &format!("{}", runtime_dir.display())])?; + write_status(status_fd, "Starting containerd..."); + let setup_handle = std::thread::spawn(move || -> Result<(), String> { let active = Command::new("sudo") .args(["systemctl", "is-active", "containerd"]) @@ -88,6 +100,7 @@ pub fn run( if !passphrase.is_empty() { if let Some(ref hp) = home_path { if hp.is_dir() { + write_status(status_fd, "Unsealing home..."); unseal_home(hp, &passphrase)?; } } @@ -96,12 +109,14 @@ pub fn run( let cleanup_patterns = config.effective_session_cleanup(); if let Some(ref hp) = home_path { eprintln!("bunkerbox: cleaning up home directory..."); + write_status(status_fd, "Cleaning home..."); cleanup_home_junk(hp, &cleanup_patterns); } let session_mb = config.session_mb(); let session_dir: Option = if session_mb > 0 { if let Some(ref hp) = home_path { + write_status(status_fd, "Setting up session image..."); Some(setup_session(hp, session_mb, uid, gid)?) } else { None @@ -118,8 +133,14 @@ pub fn run( let bridge_firewall = config.network == Some(NetworkMode::Bridge) && config.allow.is_some(); - let resolv_conf = if config.network.is_some() { Some(write_resolv_conf()?) } else { None }; + let resolv_conf = if config.network.is_some() { + write_status(status_fd, "Writing resolv.conf..."); + Some(write_resolv_conf()?) + } else { + None + }; if bridge_firewall { + write_status(status_fd, "Applying firewall rules..."); ensure_bridge_egress_firewall(config, resolv_conf.as_deref())?; } let resolv_conf_mount = resolv_conf.as_ref().map(|path| format!("type=bind,src={},dst=/etc/resolv.conf,options=rbind:ro", path.display())); @@ -196,8 +217,10 @@ pub fn run( args.push(&config.image); args.push(container_name); + write_status(status_fd, "Starting container..."); let result = run_command("sudo", &args); + write_status(status_fd, "Container stopped"); if bridge_firewall { let _ = remove_bridge_egress_firewall(); } @@ -208,6 +231,7 @@ pub fn run( if let Some(ref sd) = session_dir { if let Some(ref hp) = home_path { + write_status(status_fd, "Saving session..."); let cleanup = config.effective_session_cleanup(); teardown_session(hp, sd, &cleanup); } @@ -216,6 +240,7 @@ pub fn run( if !passphrase.is_empty() { if let Some(ref hp) = home_path { if hp.is_dir() { + write_status(status_fd, "Sealing home..."); seal_home(hp, encrypt_patterns, &passphrase)?; } } diff --git a/src/main.rs b/src/main.rs index 40c6edd..1ada4a4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,11 @@ use bunkerbox::cfg::{ProjectConfig, WorkspaceMode}; -use bunkerbox::{cfg, cfgsetup, clidef, cmdrun, daemon, kata, overlay, tui, workspace}; +use bunkerbox::{cfg, cfgsetup, clidef, cmdrun, daemon, kata, overlay, tui, vscomm, workspace}; use std::cell::RefCell; use std::ffi::OsString; use std::os::unix::io::RawFd; use std::path::{Path, PathBuf}; use std::rc::Rc; +use std::sync::{Arc, Mutex}; fn main() { if let Err(err) = run() { @@ -170,11 +171,11 @@ fn run_packaged_runtime(config: cfg::RuntimeConfig, workspace_override: Option 0, Err(e) => { eprintln!("bunkerbox: {e}"); 1 } }; + unsafe { libc::close(status_fd) }; std::process::exit(code); } - unsafe { libc::close(pipe_w) }; + unsafe { libc::close(child_fd) }; + + let overlay: Arc> = Arc::new(Mutex::new(tui::OverlayState::new("Starting...".into()))); + let overlay_clone = overlay.clone(); let tui_result = tui::event_loop( master, rows, cols, - Some(pipe_r), - Some(|path_bytes: Vec| { + parent_fd, + move |path_bytes: Vec| { let wp = PathBuf::from(String::from_utf8_lossy(&path_bytes).into_owned()); if !passthrough.is_empty() { *daemon_clone.borrow_mut() = Some(daemon::VsockDaemon::start(passthrough, env_mode, wp, profiles, share_dir_owned, merged_allow)?); } + + let overlay = overlay_clone; + tokio::spawn(async move { + status_listener(overlay).await; + }); + Ok(()) - }), + }, + overlay, ); let mut status: i32 = 0; @@ -242,6 +257,51 @@ fn run_packaged_runtime(config: cfg::RuntimeConfig, workspace_override: Option>) { + use tokio::io::AsyncReadExt; + use tokio_vsock::VsockListener; + + let listener = match VsockListener::bind(tokio_vsock::VsockAddr::new(libc::VMADDR_CID_ANY, vscomm::STATUS_PORT)) { + Ok(l) => l, + Err(_) => return, + }; + + loop { + let (mut stream, _peer) = match listener.accept().await { + Ok(c) => c, + Err(_) => continue, + }; + + let overlay = overlay.clone(); + tokio::spawn(async move { + let mut header = [0u8; 6]; + if stream.read_exact(&mut header).await.is_err() { + return; + } + + let frame_type_raw = u16::from_le_bytes([header[0], header[1]]); + let payload_len = u32::from_le_bytes([header[2], header[3], header[4], header[5]]) as usize; + + let Some(ft) = vscomm::FrameType::from_u16(frame_type_raw) else { + return; + }; + + if !matches!(ft, vscomm::FrameType::UiCommand) { + return; + } + + let mut payload = vec![0u8; payload_len]; + if payload_len > 0 && stream.read_exact(&mut payload).await.is_err() { + return; + } + + if let Some((widget, cmd, val)) = vscomm::decode_ui_payload(&payload) { + tui::dispatch_ui_command(&mut overlay.lock().unwrap(), widget, cmd, val); + } + }); + } +} + fn print_subcommand_help(name: &str) -> Result<(), String> { let mut cli = clidef::cli(env!("CARGO_PKG_VERSION")); let subcommand = cli.find_subcommand_mut(name).ok_or_else(|| format!("unknown command: {name}"))?; diff --git a/src/tui.rs b/src/tui.rs index 09c301f..b543840 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -2,6 +2,7 @@ use std::io; use std::os::fd::AsRawFd; use std::os::unix::io::RawFd; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; use crossterm::cursor; use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; @@ -17,6 +18,65 @@ extern "C" fn handle_sigwinch(_: libc::c_int) { RESIZED.store(true, Ordering::SeqCst); } +const WIDGET_PROGRESS: &str = "progress"; +const WIDGET_STATUS: &str = "status"; +const WIDGET_POPUP: &str = "popup"; + +const CMD_SHOW: &str = "show"; +const CMD_HIDE: &str = "hide"; +const CMD_SET: &str = "set"; +const CMD_CLEAR: &str = "clear"; + +pub struct OverlayState { + pub status_text: String, + pub progress_percent: Option, + pub progress_label: Option, + pub popup_text: Option, +} + +impl OverlayState { + pub fn new(status_text: String) -> Self { + Self { status_text, progress_percent: None, progress_label: None, popup_text: None } + } +} + +pub fn dispatch_ui_command(state: &mut OverlayState, widget: &str, command: &str, value: &str) { + match (widget, command) { + (WIDGET_PROGRESS, CMD_SET) => { + if let Ok(pct) = value.parse::() { + state.progress_percent = Some(pct.clamp(0.0, 1.0)); + } + } + (WIDGET_PROGRESS, CMD_SHOW) => { + if let Some((pct, label)) = value.split_once(';') { + if let Ok(pct) = pct.trim().parse::() { + state.progress_percent = Some(pct.clamp(0.0, 1.0)); + } + state.progress_label = Some(label.trim().to_string()); + } else if let Ok(pct) = value.parse::() { + state.progress_percent = Some(pct.clamp(0.0, 1.0)); + } + } + (WIDGET_PROGRESS, CMD_HIDE) => { + state.progress_percent = None; + state.progress_label = None; + } + (WIDGET_STATUS, CMD_SET) => { + state.status_text = value.to_string(); + } + (WIDGET_STATUS, CMD_CLEAR) => { + state.status_text.clear(); + } + (WIDGET_POPUP, CMD_SHOW) => { + state.popup_text = Some(value.to_string()); + } + (WIDGET_POPUP, CMD_HIDE) => { + state.popup_text = None; + } + _ => {} + } +} + /// Terminal emulator wrapper around [`vt100::Parser`] with DEC Special Graphics /// character set translation and HVP-to-CUP normalization. struct Term { @@ -202,12 +262,15 @@ fn push_dec_special_graphic(out: &mut Vec, byte: u8) { /// /// Forwards real keystrokes to the PTY master, parses terminal output through /// [`vt100::Parser`], and draws each frame with full 24-bit color plus a -/// floating "Bunkerbox" overlay in the top-right corner. +/// floating status overlay in the top-right corner. +/// +/// `status_fd` is polled continuously for newline-delimited messages. The +/// first message is forwarded to `on_setup` (workspace path); subsequent +/// messages update `overlay.status_text`. /// -/// If `setup_fd` is provided, the loop also polls it for a single message; -/// when readable, all bytes are read, the fd is closed, and `on_setup` is -/// called once with the received data before polling continues. -pub fn event_loop(master_fd: RawFd, rows: u16, cols: u16, setup_fd: Option, on_setup: Option) -> Result<(), String> +/// `overlay` is shared with the VSOCK status listener so VM-originated +/// UI commands can update popups, progress bars, and status text. +pub fn event_loop(master_fd: RawFd, rows: u16, cols: u16, status_fd: RawFd, on_setup: F, overlay: Arc>) -> Result<(), String> where F: FnOnce(Vec) -> Result<(), String>, { @@ -229,8 +292,8 @@ where let mut last_rows = rows; let mut last_cols = cols; - let mut setup_fd = setup_fd; - let mut on_setup = on_setup; + let mut on_setup = Some(on_setup); + let mut status_buf = Vec::new(); unsafe { libc::signal(libc::SIGWINCH, handle_sigwinch as *const () as libc::sighandler_t); @@ -251,14 +314,13 @@ where } } - let nfds: libc::c_ulong = if setup_fd.is_some() { 3 } else { 2 }; let mut fds = [ libc::pollfd { fd: master_fd, events: libc::POLLIN, revents: 0 }, libc::pollfd { fd: stdin_fd, events: libc::POLLIN, revents: 0 }, - libc::pollfd { fd: setup_fd.unwrap_or(-1), events: libc::POLLIN, revents: 0 }, + libc::pollfd { fd: status_fd, events: libc::POLLIN, revents: 0 }, ]; - let ret = unsafe { libc::poll(fds.as_mut_ptr(), nfds, 16) }; + let ret = unsafe { libc::poll(fds.as_mut_ptr(), 3, 16) }; if ret == -1 { let err = io::Error::last_os_error(); @@ -288,27 +350,28 @@ where } } - if let Some(fd) = setup_fd { - if fds[2].revents & (libc::POLLIN | libc::POLLHUP) != 0 { - let mut buf = Vec::new(); - let mut chunk = [0u8; 256]; - loop { - let n = unsafe { libc::read(fd, chunk.as_mut_ptr() as *mut libc::c_void, chunk.len()) }; - if n > 0 { - buf.extend_from_slice(&chunk[..n as usize]); - } else { - break; - } - } - unsafe { libc::close(fd) }; - setup_fd = None; + if fds[2].revents & (libc::POLLIN | libc::POLLHUP) != 0 { + let mut chunk = [0u8; 256]; + let n = unsafe { libc::read(status_fd, chunk.as_mut_ptr() as *mut libc::c_void, chunk.len()) }; + if n > 0 { + status_buf.extend_from_slice(&chunk[..n as usize]); + } + while let Some(pos) = status_buf.iter().position(|&b| b == b'\n') { + let line = String::from_utf8_lossy(&status_buf[..pos]).into_owned(); + status_buf.drain(..=pos); if let Some(cb) = on_setup.take() { - cb(buf)?; + cb(line.into_bytes())?; + } else { + let mut state = overlay.lock().unwrap(); + state.status_text = line; } } } - terminal.draw(|f| render_frame(f, term.screen())).map_err(|e| format!("draw: {e}"))?; + { + let state = overlay.lock().unwrap(); + terminal.draw(|f| render_frame(f, term.screen(), &state)).map_err(|e| format!("draw: {e}"))?; + } } terminal.backend_mut().execute(LeaveAlternateScreen).ok(); @@ -331,9 +394,9 @@ fn to_ratatui_color(c: vt100::Color) -> Color { } /// Renders one frame: writes every vt100 screen cell to the ratatui buffer -/// with full color and attributes, draws the floating "Bunkerbox" overlay, -/// and positions the cursor. -fn render_frame(f: &mut Frame, screen: &vt100::Screen) { +/// with full color and attributes, then draws overlay widgets (popup, +/// progress bar, status box) on top. +fn render_frame(f: &mut Frame, screen: &vt100::Screen, overlay: &OverlayState) { let area = f.area(); let (rows, cols) = screen.size(); let max_rows = area.height.min(rows); @@ -396,19 +459,60 @@ fn render_frame(f: &mut Frame, screen: &vt100::Screen) { } } - let win_w = 15u16; - let win_h = 3u16; - let win_x = area.width.saturating_sub(win_w + 4); - let win_y = 2; - let rect = Rect::new(win_x, win_y, win_w, win_h); - - f.render_widget(Clear, rect); - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)) - .title(" Bunkerbox ") - .title_style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD)); - f.render_widget(block, rect); + if let Some(ref text) = overlay.popup_text { + let popup_w = (text.len() as u16 + 4).min(area.width.saturating_sub(4)); + let popup_h = 3u16; + let popup_x = (area.width.saturating_sub(popup_w)) / 2; + let popup_y = (area.height.saturating_sub(popup_h)) / 2; + let rect = Rect::new(popup_x, popup_y, popup_w, popup_h); + f.render_widget(Clear, rect); + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)) + .title(format!(" {} ", text)) + .title_style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD)); + f.render_widget(block, rect); + } + + if let Some(pct) = overlay.progress_percent { + let bar_w = (area.width.saturating_sub(8)).min(60); + let bar_x = (area.width.saturating_sub(bar_w)) / 2; + let bar_y = area.height.saturating_sub(4); + let rect = Rect::new(bar_x, bar_y, bar_w, 3u16); + f.render_widget(Clear, rect); + + let pct_label = format!(" {:.0}% ", pct * 100.0); + let label = overlay.progress_label.as_deref().unwrap_or(""); + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(format!(" {label} {pct_label}")) + .title_style(Style::default().fg(Color::White)); + f.render_widget(block, rect); + + let gauge = ratatui::widgets::Gauge::default() + .gauge_style(Style::default().fg(Color::Cyan).bg(Color::DarkGray)) + .percent((pct * 100.0) as u16); + let inner = Rect::new(bar_x + 1, bar_y + 1, bar_w.saturating_sub(2), 1); + f.render_widget(gauge, inner); + } + + { + let title = format!(" {} ", overlay.status_text); + let win_w = (title.len() as u16).max(15); + let win_h = 3u16; + let win_x = area.width.saturating_sub(win_w + 4); + let win_y = 2; + let rect = Rect::new(win_x, win_y, win_w, win_h); + + f.render_widget(Clear, rect); + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(title) + .title_style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD)); + f.render_widget(block, rect); + } let (cursor_row, cursor_col) = screen.cursor_position(); if cursor_row < max_rows && cursor_col < max_cols { diff --git a/src/vscomm/mod.rs b/src/vscomm/mod.rs index a744b48..ab31cb3 100644 --- a/src/vscomm/mod.rs +++ b/src/vscomm/mod.rs @@ -5,6 +5,7 @@ use std::io::{self, Read, Write}; pub mod buildsys; pub const VSOCK_PORT: u32 = 9999; +pub const STATUS_PORT: u32 = 9998; pub const VSCOMM_BIN_DIR: &str = "/usr/local/bunkerbox/bin"; #[repr(u16)] @@ -15,6 +16,7 @@ pub enum FrameType { Stderr = 3, Exit = 4, Disconnect = 5, + UiCommand = 10, } impl FrameType { @@ -25,6 +27,7 @@ impl FrameType { 3 => Some(Self::Stderr), 4 => Some(Self::Exit), 5 => Some(Self::Disconnect), + 10 => Some(Self::UiCommand), _ => None, } } @@ -150,3 +153,22 @@ impl ExecRequest { Ok(Self { cwd, command, args, env }) } } + +pub fn encode_ui_payload(widget: &str, command: &str, value: &str) -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(widget.as_bytes()); + buf.push(0); + buf.extend_from_slice(command.as_bytes()); + buf.push(0); + buf.extend_from_slice(value.as_bytes()); + buf.push(0); + buf +} + +pub fn decode_ui_payload(payload: &[u8]) -> Option<(&str, &str, &str)> { + let mut parts = payload.split(|&b| b == 0); + let widget = std::str::from_utf8(parts.next()?).ok()?; + let command = std::str::from_utf8(parts.next()?).ok()?; + let value = std::str::from_utf8(parts.next()?).ok()?; + Some((widget, command, value)) +} From 758f1576d0f361d27656e67242ba9c6ca9d5c381 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Mon, 27 Jul 2026 17:09:05 +0200 Subject: [PATCH 08/42] Lintfixes --- src/kata.rs | 4 +++- src/tui.rs | 5 ++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/kata.rs b/src/kata.rs index eb67f57..b5536f7 100644 --- a/src/kata.rs +++ b/src/kata.rs @@ -35,7 +35,9 @@ pub fn run( fn write_status(fd: RawFd, msg: &str) { let mut buf = msg.as_bytes().to_vec(); buf.push(b'\n'); - unsafe { libc::write(fd, buf.as_ptr() as *const libc::c_void, buf.len()); } + unsafe { + libc::write(fd, buf.as_ptr() as *const libc::c_void, buf.len()); + } } if !config.oci.is_file() { diff --git a/src/tui.rs b/src/tui.rs index b543840..11f4903 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -490,9 +490,8 @@ fn render_frame(f: &mut Frame, screen: &vt100::Screen, overlay: &OverlayState) { .title_style(Style::default().fg(Color::White)); f.render_widget(block, rect); - let gauge = ratatui::widgets::Gauge::default() - .gauge_style(Style::default().fg(Color::Cyan).bg(Color::DarkGray)) - .percent((pct * 100.0) as u16); + let gauge = + ratatui::widgets::Gauge::default().gauge_style(Style::default().fg(Color::Cyan).bg(Color::DarkGray)).percent((pct * 100.0) as u16); let inner = Rect::new(bar_x + 1, bar_y + 1, bar_w.saturating_sub(2), 1); f.render_widget(gauge, inner); } From e3060bf8e969f8a51bd4c3c63d1875c90815530d Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Mon, 27 Jul 2026 18:48:02 +0200 Subject: [PATCH 09/42] Add vscomm binary to talk from VM to the UI --- Cargo.toml | 4 ++ Makefile | 3 ++ images/kilocode.conf | 6 ++- src/bin/bunkerbox-image.rs | 24 +++++++++- src/bin/bunkerbox-status.rs | 93 +++++++++++++++++++++++++++++++++++++ src/tui.rs | 2 +- 6 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 src/bin/bunkerbox-status.rs diff --git a/Cargo.toml b/Cargo.toml index b4d4e39..8ef654b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,3 +44,7 @@ path = "src/main.rs" [[bin]] name = "bunkerbox-vscomm" path = "src/bin/bunkerbox-vscomm.rs" + +[[bin]] +name = "bunkerbox-status" +path = "src/bin/bunkerbox-status.rs" diff --git a/Makefile b/Makefile index e2f0604..2499b3c 100644 --- a/Makefile +++ b/Makefile @@ -44,10 +44,12 @@ ensure-toolchain: dev: ensure-toolchain cargo build cargo build --bin bunkerbox-vscomm --target $(VSCOMM_TARGET) + cargo build --bin bunkerbox-status --target $(VSCOMM_TARGET) release: ensure-toolchain cargo build --release cargo build --bin bunkerbox-vscomm --target $(VSCOMM_TARGET) --release + cargo build --bin bunkerbox-status --target $(VSCOMM_TARGET) --release check: cargo fmt --all @@ -64,6 +66,7 @@ setup: dev musl-vscomm: ensure-toolchain cargo build --bin bunkerbox-vscomm --target $(VSCOMM_TARGET) + cargo build --bin bunkerbox-status --target $(VSCOMM_TARGET) image: dev @if [ -z "$(IMAGE)" ]; then echo "usage: make image IMAGE=images/name.conf" >&2; exit 1; fi diff --git a/images/kilocode.conf b/images/kilocode.conf index 0208493..c9dd9fa 100644 --- a/images/kilocode.conf +++ b/images/kilocode.conf @@ -13,10 +13,10 @@ hooks: before-home-load: # runs before app starts - before-app: + before-app: "bunkerbox-status status clear" # runs after app exits - after-app: + after-app: "bunkerbox-status status set Exiting kilocode..." # runs only when app exits non-zero app-error: @@ -54,6 +54,8 @@ containerfile: | COPY bunkerbox-vscomm /usr/local/bunkerbox/bin/bunkerbox-vscomm + COPY bunkerbox-status /usr/local/bunkerbox/bin/bunkerbox-status + ENV HOME=/home/bunkerbox \ XDG_CONFIG_HOME=/home/bunkerbox/.config \ XDG_DATA_HOME=/home/bunkerbox/.local/share \ diff --git a/src/bin/bunkerbox-image.rs b/src/bin/bunkerbox-image.rs index 50d2c07..5a1a59c 100644 --- a/src/bin/bunkerbox-image.rs +++ b/src/bin/bunkerbox-image.rs @@ -281,11 +281,16 @@ fn write_build_context(config: &ImageConfig, build_dir: &Path) -> Result<(), Str fs::copy(&vscomm_path, &dest).map_err(|err| format!("failed to copy vscomm binary {}: {err}", dest.display()))?; fs::set_permissions(&dest, fs::Permissions::from_mode(0o755)).map_err(|err| format!("failed to chmod {}: {err}", dest.display()))?; + let status_path = find_status_binary()?; + let dest = build_dir.join("bunkerbox-status"); + fs::copy(&status_path, &dest).map_err(|err| format!("failed to copy status binary {}: {err}", dest.display()))?; + fs::set_permissions(&dest, fs::Permissions::from_mode(0o755)).map_err(|err| format!("failed to chmod {}: {err}", dest.display()))?; + for file in &config.files { if file.path.is_absolute() || file.path.components().any(|part| matches!(part, std::path::Component::ParentDir)) { return Err(format!("unsafe build file path: {}", file.path.display())); } - if file.path == Path::new("bunker-entrypoint") || file.path == Path::new("bunkerbox-vscomm") { + if file.path == Path::new("bunker-entrypoint") || file.path == Path::new("bunkerbox-vscomm") || file.path == Path::new("bunkerbox-status") { return Err(format!("image config files must not override reserved file: {}", file.path.display())); } @@ -319,6 +324,23 @@ fn find_vscomm_binary() -> Result { .to_string()) } +fn find_status_binary() -> Result { + let exe = env::current_exe().map_err(|e| format!("failed to locate self: {e}"))?; + let parent = exe.parent().ok_or_else(|| "failed to determine parent directory".to_string())?; + let target_dir = parent.parent().ok_or_else(|| "failed to determine target directory".to_string())?; + + for profile in &["release", "debug"] { + let musl_candidate = target_dir.join("x86_64-unknown-linux-musl").join(profile).join("bunkerbox-status"); + if musl_candidate.is_file() { + return Ok(musl_candidate); + } + } + + Err("musl-static bunkerbox-status not found in target/x86_64-unknown-linux-musl/{release,debug}/\n\ + build it first: make musl-vscomm" + .to_string()) +} + fn podman_build(config: &ImageConfig, build_dir: &Path) -> Result<(), String> { let mut args = vec!["build".to_string(), "--no-cache".to_string()]; diff --git a/src/bin/bunkerbox-status.rs b/src/bin/bunkerbox-status.rs new file mode 100644 index 0000000..7dc14ec --- /dev/null +++ b/src/bin/bunkerbox-status.rs @@ -0,0 +1,93 @@ +#[path = "../vscomm/mod.rs"] +mod vscomm; + +use std::env; +use std::io::{self, Read, Write}; +use std::mem; +use std::os::unix::io::RawFd; +use vscomm::{Frame, FrameType, STATUS_PORT}; + +const HOST_CID: u32 = 2; + +fn main() { + let result = run(); + if let Err(err) = result { + eprintln!("bunkerbox-status: {err}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), String> { + let args: Vec = env::args().collect(); + + let widget = args.get(1).ok_or_else(|| "usage: bunkerbox-status [value]".to_string())?; + let command = args.get(2).ok_or_else(|| "usage: bunkerbox-status [value]".to_string())?; + let value = args.get(3).map(|s| s.as_str()).unwrap_or(""); + + if !widget.is_empty() && !command.is_empty() { + let payload = vscomm::encode_ui_payload(widget, command, value); + let frame = Frame::new(FrameType::UiCommand, payload); + + let mut stream = vsock_connect(HOST_CID, STATUS_PORT).map_err(|e| format!("vsock connect: {e}"))?; + frame.write(&mut stream).map_err(|e| format!("send: {e}"))?; + stream.flush().map_err(|e| format!("flush: {e}"))?; + } + + Ok(()) +} + +fn vsock_connect(cid: u32, port: u32) -> io::Result { + unsafe { + let fd = libc::socket(libc::AF_VSOCK, libc::SOCK_STREAM, 0); + if fd < 0 { + return Err(io::Error::last_os_error()); + } + + let addr = libc::sockaddr_vm { svm_family: libc::AF_VSOCK as u16, svm_reserved1: 0, svm_port: port, svm_cid: cid, svm_zero: [0u8; 4] }; + + let addr_ptr = &addr as *const libc::sockaddr_vm as *const libc::sockaddr; + let addr_len = mem::size_of::() as libc::socklen_t; + + if libc::connect(fd, addr_ptr, addr_len) < 0 { + let err = io::Error::last_os_error(); + libc::close(fd); + return Err(err); + } + + Ok(VsockStream { fd }) + } +} + +struct VsockStream { + fd: RawFd, +} + +impl Read for VsockStream { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + let ret = unsafe { libc::read(self.fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) }; + if ret < 0 { + return Err(io::Error::last_os_error()); + } + Ok(ret as usize) + } +} + +impl Write for VsockStream { + fn write(&mut self, buf: &[u8]) -> io::Result { + let ret = unsafe { libc::write(self.fd, buf.as_ptr() as *const libc::c_void, buf.len()) }; + if ret < 0 { + return Err(io::Error::last_os_error()); + } + Ok(ret as usize) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +impl Drop for VsockStream { + fn drop(&mut self) { + unsafe { libc::close(self.fd) }; + } +} diff --git a/src/tui.rs b/src/tui.rs index 11f4903..4c08f60 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -496,7 +496,7 @@ fn render_frame(f: &mut Frame, screen: &vt100::Screen, overlay: &OverlayState) { f.render_widget(gauge, inner); } - { + if !overlay.status_text.is_empty() { let title = format!(" {} ", overlay.status_text); let win_w = (title.len() as u16).max(15); let win_h = 3u16; From ca0a2ca476e0a13dc913cfbad9785d67ee51aac8 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 28 Jul 2026 11:36:50 +0200 Subject: [PATCH 10/42] Update on better TUI interaction --- images/kilocode.conf | 4 +- src/bin/bunkerbox-status.rs | 9 +++-- src/kata.rs | 11 ++++++ src/main.rs | 4 +- src/tui.rs | 74 ++++++++++++++++++++++++++++++++++--- src/vscomm/mod.rs | 48 ++++++++++++++++++++++-- 6 files changed, 134 insertions(+), 16 deletions(-) diff --git a/images/kilocode.conf b/images/kilocode.conf index c9dd9fa..12dafac 100644 --- a/images/kilocode.conf +++ b/images/kilocode.conf @@ -13,10 +13,10 @@ hooks: before-home-load: # runs before app starts - before-app: "bunkerbox-status status clear" + before-app: # runs after app exits - after-app: "bunkerbox-status status set Exiting kilocode..." + after-app: "bunkerbox-status status set '' 'Exiting kilocode...'" # runs only when app exits non-zero app-error: diff --git a/src/bin/bunkerbox-status.rs b/src/bin/bunkerbox-status.rs index 7dc14ec..35751b9 100644 --- a/src/bin/bunkerbox-status.rs +++ b/src/bin/bunkerbox-status.rs @@ -20,12 +20,13 @@ fn main() { fn run() -> Result<(), String> { let args: Vec = env::args().collect(); - let widget = args.get(1).ok_or_else(|| "usage: bunkerbox-status [value]".to_string())?; - let command = args.get(2).ok_or_else(|| "usage: bunkerbox-status [value]".to_string())?; - let value = args.get(3).map(|s| s.as_str()).unwrap_or(""); + let widget = args.get(1).ok_or_else(|| "usage: bunkerbox-status [options] [value]".to_string())?; + let command = args.get(2).ok_or_else(|| "usage: bunkerbox-status [options] [value]".to_string())?; + let options = args.get(3).map(|s| s.as_str()).unwrap_or(""); + let value = args.get(4).map(|s| s.as_str()).unwrap_or(""); if !widget.is_empty() && !command.is_empty() { - let payload = vscomm::encode_ui_payload(widget, command, value); + let payload = vscomm::encode_ui_payload(widget, command, options, value); let frame = Frame::new(FrameType::UiCommand, payload); let mut stream = vsock_connect(HOST_CID, STATUS_PORT).map_err(|e| format!("vsock connect: {e}"))?; diff --git a/src/kata.rs b/src/kata.rs index b5536f7..54fa1c8 100644 --- a/src/kata.rs +++ b/src/kata.rs @@ -220,6 +220,17 @@ pub fn run( args.push(container_name); write_status(status_fd, "Starting container..."); + + write_status(status_fd, "Connected"); + + { + let payload = crate::vscomm::encode_ui_payload("status", "clear", "ON_PTY,SEC_3", ""); + let mut buf = b"@".to_vec(); + buf.extend_from_slice(&payload); + buf.push(b'\n'); + unsafe { libc::write(status_fd, buf.as_ptr() as *const libc::c_void, buf.len()); } + } + let result = run_command("sudo", &args); write_status(status_fd, "Container stopped"); diff --git a/src/main.rs b/src/main.rs index 1ada4a4..5e28f36 100644 --- a/src/main.rs +++ b/src/main.rs @@ -295,8 +295,8 @@ async fn status_listener(overlay: Arc>) { return; } - if let Some((widget, cmd, val)) = vscomm::decode_ui_payload(&payload) { - tui::dispatch_ui_command(&mut overlay.lock().unwrap(), widget, cmd, val); + if let Some((widget, cmd, opts, val)) = vscomm::decode_ui_payload(&payload) { + tui::dispatch_ui_command(&mut overlay.lock().unwrap(), widget, cmd, opts, val); } }); } diff --git a/src/tui.rs b/src/tui.rs index 4c08f60..ce0aacf 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -3,6 +3,7 @@ use std::os::fd::AsRawFd; use std::os::unix::io::RawFd; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; +use std::time::Instant; use crossterm::cursor; use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; @@ -12,6 +13,8 @@ use ratatui::prelude::*; use ratatui::widgets::{Block, Borders, Clear}; use ratatui::Terminal; +use crate::vscomm::{self, Trigger, parse_triggers}; + static RESIZED: AtomicBool = AtomicBool::new(false); extern "C" fn handle_sigwinch(_: libc::c_int) { @@ -27,20 +30,43 @@ const CMD_HIDE: &str = "hide"; const CMD_SET: &str = "set"; const CMD_CLEAR: &str = "clear"; +pub struct PendingAction { + pub widget: String, + pub command: String, + pub triggers: Vec, + pub value: String, + pub enqueued_at: Instant, + pub first_pty_at: Option, +} + pub struct OverlayState { pub status_text: String, pub progress_percent: Option, pub progress_label: Option, pub popup_text: Option, + pub pending: Vec, } impl OverlayState { pub fn new(status_text: String) -> Self { - Self { status_text, progress_percent: None, progress_label: None, popup_text: None } + Self { status_text, progress_percent: None, progress_label: None, popup_text: None, pending: Vec::new() } } } -pub fn dispatch_ui_command(state: &mut OverlayState, widget: &str, command: &str, value: &str) { +pub fn dispatch_ui_command(state: &mut OverlayState, widget: &str, command: &str, options: &str, value: &str) { + let triggers = parse_triggers(options); + if !triggers.is_empty() { + state.pending.push(PendingAction { + widget: widget.to_string(), + command: command.to_string(), + triggers, + value: value.to_string(), + enqueued_at: Instant::now(), + first_pty_at: None, + }); + return; + } + match (widget, command) { (WIDGET_PROGRESS, CMD_SET) => { if let Ok(pct) = value.parse::() { @@ -332,10 +358,13 @@ where return Err(format!("poll: {err}")); } + let mut pty_output = false; + if fds[0].revents & (libc::POLLIN | libc::POLLHUP) != 0 { let n = unsafe { libc::read(master_fd, pty_buf.as_mut_ptr() as *mut libc::c_void, pty_buf.len()) }; if n > 0 { term.process(&pty_buf[..n as usize]); + pty_output = true; } else { break; } @@ -361,15 +390,50 @@ where status_buf.drain(..=pos); if let Some(cb) = on_setup.take() { cb(line.into_bytes())?; + } else if let Some(cmd) = line.strip_prefix('@') { + if let Some((widget, cmd, opts, val)) = vscomm::decode_ui_payload(cmd.as_bytes()) { + let mut state = overlay.lock().unwrap(); + dispatch_ui_command(&mut state, widget, cmd, opts, val); + } } else { - let mut state = overlay.lock().unwrap(); - state.status_text = line; + overlay.lock().unwrap().status_text = line; } } } { - let state = overlay.lock().unwrap(); + let mut state = overlay.lock().unwrap(); + let now = Instant::now(); + + if pty_output { + for action in &mut state.pending { + if action.first_pty_at.is_none() && action.triggers.iter().any(|t| matches!(t, Trigger::OnPty | Trigger::DelayMsAfterPty(_))) { + action.first_pty_at = Some(now); + } + } + } + + let mut i = 0; + while i < state.pending.len() { + let fire = state.pending[i].triggers.iter().any(|t| match t { + Trigger::OnPty => pty_output, + Trigger::DelayMs(d) => (now - state.pending[i].enqueued_at).as_millis() as u64 >= *d, + Trigger::DelayMsAfterPty(d) => { + if let Some(first) = state.pending[i].first_pty_at { + (now - first).as_millis() as u64 >= *d + } else { + false + } + } + }); + if fire { + let action = state.pending.remove(i); + dispatch_ui_command(&mut state, &action.widget, &action.command, "", &action.value); + } else { + i += 1; + } + } + terminal.draw(|f| render_frame(f, term.screen(), &state)).map_err(|e| format!("draw: {e}"))?; } } diff --git a/src/vscomm/mod.rs b/src/vscomm/mod.rs index ab31cb3..ea5b843 100644 --- a/src/vscomm/mod.rs +++ b/src/vscomm/mod.rs @@ -154,21 +154,63 @@ impl ExecRequest { } } -pub fn encode_ui_payload(widget: &str, command: &str, value: &str) -> Vec { +pub fn encode_ui_payload(widget: &str, command: &str, options: &str, value: &str) -> Vec { let mut buf = Vec::new(); buf.extend_from_slice(widget.as_bytes()); buf.push(0); buf.extend_from_slice(command.as_bytes()); buf.push(0); + buf.extend_from_slice(options.as_bytes()); + buf.push(0); buf.extend_from_slice(value.as_bytes()); buf.push(0); buf } -pub fn decode_ui_payload(payload: &[u8]) -> Option<(&str, &str, &str)> { +pub fn decode_ui_payload(payload: &[u8]) -> Option<(&str, &str, &str, &str)> { let mut parts = payload.split(|&b| b == 0); let widget = std::str::from_utf8(parts.next()?).ok()?; let command = std::str::from_utf8(parts.next()?).ok()?; + let options = std::str::from_utf8(parts.next()?).ok()?; let value = std::str::from_utf8(parts.next()?).ok()?; - Some((widget, command, value)) + Some((widget, command, options, value)) +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Trigger { + OnPty, + DelayMs(u64), + DelayMsAfterPty(u64), +} + +pub fn parse_triggers(options: &str) -> Vec { + if options.is_empty() { + return Vec::new(); + } + + let mut has_on_pty = false; + let mut delay_ms: Option = None; + + for s in options.split(',') { + let s = s.trim(); + if s == "ON_PTY" { + has_on_pty = true; + } else if let Some(secs) = s.strip_prefix("SEC_") { + if let Ok(secs) = secs.parse::() { + delay_ms = Some((secs * 1000.0) as u64); + } + } + } + + if has_on_pty { + if let Some(ms) = delay_ms { + vec![Trigger::DelayMsAfterPty(ms)] + } else { + vec![Trigger::OnPty] + } + } else if let Some(ms) = delay_ms { + vec![Trigger::DelayMs(ms)] + } else { + Vec::new() + } } From ed7c951bdeb9222fbff72c37f746763c375480db Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 28 Jul 2026 11:37:11 +0200 Subject: [PATCH 11/42] Linter --- src/kata.rs | 4 +++- src/tui.rs | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/kata.rs b/src/kata.rs index 54fa1c8..5e80f48 100644 --- a/src/kata.rs +++ b/src/kata.rs @@ -228,7 +228,9 @@ pub fn run( let mut buf = b"@".to_vec(); buf.extend_from_slice(&payload); buf.push(b'\n'); - unsafe { libc::write(status_fd, buf.as_ptr() as *const libc::c_void, buf.len()); } + unsafe { + libc::write(status_fd, buf.as_ptr() as *const libc::c_void, buf.len()); + } } let result = run_command("sudo", &args); diff --git a/src/tui.rs b/src/tui.rs index ce0aacf..33a754c 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -13,7 +13,7 @@ use ratatui::prelude::*; use ratatui::widgets::{Block, Borders, Clear}; use ratatui::Terminal; -use crate::vscomm::{self, Trigger, parse_triggers}; +use crate::vscomm::{self, parse_triggers, Trigger}; static RESIZED: AtomicBool = AtomicBool::new(false); From 07d7a579bc2e579229d381564b09880ae0b609c0 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 29 Jul 2026 17:57:30 +0200 Subject: [PATCH 12/42] Add palette --- src/tui/palette.rs | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 src/tui/palette.rs diff --git a/src/tui/palette.rs b/src/tui/palette.rs new file mode 100644 index 0000000..c08719e --- /dev/null +++ b/src/tui/palette.rs @@ -0,0 +1,43 @@ +use ratatui::style::Color; + +// --------------------------------------------------------------------------- +// Industrial / bunker palette — cold steel, amber warning lights, radar cyan +// --------------------------------------------------------------------------- + +// ---- Neutral foundation ---- + +pub const BG_0: Color = Color::Indexed(232); +pub const BG_1: Color = Color::Indexed(233); +pub const BG_2: Color = Color::Indexed(235); +pub const BG_3: Color = Color::Indexed(237); + +pub const GRAY_0: Color = Color::Indexed(239); +pub const GRAY_1: Color = Color::Indexed(244); +pub const GRAY_2: Color = Color::Indexed(249); + +// ---- Semantic aliases ---- + +pub const SURFACE: Color = Color::Indexed(236); +pub const FG: Color = Color::Indexed(253); +pub const MUTED: Color = Color::Indexed(243); +pub const FAINT: Color = Color::Indexed(238); +pub const BORDER: Color = Color::Indexed(240); + +pub const ACCENT: Color = Color::Indexed(208); +pub const PROCESSING: Color = Color::Indexed(39); +pub const SUCCESS: Color = Color::Indexed(42); +pub const ERROR: Color = Color::Indexed(197); +pub const WARNING: Color = Color::Indexed(178); +pub const HIGHLIGHT: Color = Color::Indexed(133); + +pub const POPUP_BG: Color = SURFACE; + +// ---- MS-DOS shadow colours ---- + +pub const SHADOW_BG: Color = Color::Black; +pub const SHADOW_FG: Color = SURFACE; + +// ---- Absolute contrast ---- + +pub const WHITE: Color = Color::White; +pub const BLACK: Color = Color::Black; From 5d036e4d50ea418be3f59d5b1c1053b1505e4cc9 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 29 Jul 2026 17:57:37 +0200 Subject: [PATCH 13/42] Add popups --- src/tui/popup.rs | 346 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100644 src/tui/popup.rs diff --git a/src/tui/popup.rs b/src/tui/popup.rs new file mode 100644 index 0000000..abf48b6 --- /dev/null +++ b/src/tui/popup.rs @@ -0,0 +1,346 @@ +use std::time::Instant; + +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Position, Rect}, + prelude::{Buffer, Style}, + text::{Line, Span}, + widgets::{Block, BorderType, Borders, Clear, Gauge, Padding, Paragraph, Widget}, +}; +use ratatui_glamour::{color::blend_2d, widgets::spinner}; + +use super::palette; + +const SPINNER_FPS_MS: u64 = 83; + +pub enum PopupContent { + Spinner { + message: String, + model: spinner::Model, + last_tick: Instant, + }, + Progress { + title: String, + percent: f64, + label: Option, + }, + Password { + title: String, + prompt: String, + }, + Info { + title: Option, + message: String, + fg: ratatui::style::Color, + }, +} + +pub struct PopupWidget { + pub visible: bool, + pub content: PopupContent, + pub border_color: ratatui::style::Color, + shadow: bool, +} + +impl PopupWidget { + pub fn new() -> Self { + Self { + visible: false, + content: PopupContent::Info { + title: None, + message: String::new(), + fg: palette::FG, + }, + border_color: palette::BORDER, + shadow: true, + } + } + + pub fn show_spinner(&mut self, message: impl Into) { + let mut model = spinner::Model::new(); + model.spinner = spinner::Spinner::mini_dot(); + model.style = Style::default().fg(palette::ACCENT); + self.content = PopupContent::Spinner { message: message.into(), model, last_tick: Instant::now() }; + self.visible = true; + } + + pub fn show_progress(&mut self, title: impl Into, percent: f64, label: Option) { + self.border_color = palette::ACCENT; + self.content = PopupContent::Progress { title: title.into(), percent: percent.clamp(0.0, 1.0), label }; + self.visible = true; + } + + pub fn set_progress(&mut self, pct: f64, lbl: Option) { + if let PopupContent::Progress { ref mut percent, ref mut label, .. } = &mut self.content { + *percent = pct.clamp(0.0, 1.0); + *label = lbl; + } + } + + pub fn show_password(&mut self, title: impl Into, prompt: impl Into) { + self.border_color = palette::ACCENT; + self.content = PopupContent::Password { title: title.into(), prompt: prompt.into() }; + self.visible = true; + } + + pub fn show_info( + &mut self, title: Option, message: impl Into, fg: Option, border_color: Option, + ) { + self.border_color = border_color.unwrap_or(palette::BORDER); + self.content = PopupContent::Info { title, message: message.into(), fg: fg.unwrap_or(palette::FG) }; + self.visible = true; + } + + pub fn tick(&mut self) { + if let PopupContent::Spinner { ref mut model, ref mut last_tick, .. } = &mut self.content { + if last_tick.elapsed().as_millis() as u64 >= SPINNER_FPS_MS { + let tick = model.tick(); + model.update(tick); + *last_tick = Instant::now(); + } + } + } + + pub fn hide(&mut self) { + self.visible = false; + } + + /// Compute the natural content dimensions (width, height) for the popup interior. + fn content_size(&self) -> (u16, u16) { + match &self.content { + PopupContent::Spinner { message, .. } => { + let w = (message.len() as u16 + 6).max(30); + (w, 5) + } + PopupContent::Progress { title, label, .. } => { + let label_w = label.as_ref().map_or(0, |l| l.len() as u16); + let w = (title.len() as u16).max(label_w).max(40); + (w + 8, 7) + } + PopupContent::Password { title: _, prompt } => { + let w = (prompt.len() as u16 + 20).max(40); + (w, 7) + } + PopupContent::Info { message, .. } => { + let lines = text_lines(message); + let max_w = message.lines().map(|l| l.len() as u16).max().unwrap_or(20); + let w = max_w.max(30); + let h = lines.max(1) + 4; + (w + 8, h) + } + } + } + + pub fn render(&self, area: Rect, buf: &mut Buffer) { + if !self.visible { + return; + } + + let (content_w, content_h) = self.content_size(); + let width = content_w.min(area.width.saturating_sub(4)).max(20); + let height = content_h.min(area.height.saturating_sub(4)); + let x = area.x + (area.width.saturating_sub(width)) / 2; + let y = area.y + (area.height.saturating_sub(height)) / 2; + let canvas = Rect { x, y, width, height }; + + Clear.render(canvas, buf); + + self.render_gradient(canvas, buf); + + let title_line = self.build_title(); + let has_title = !matches!(&self.content, PopupContent::Spinner { .. }); + + let block = Block::default() + .title(if has_title { title_line } else { Line::from("") }) + .title_alignment(Alignment::Center) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(self.border_color)) + .padding(Padding::horizontal(2)) + .style(Style::default()); + + let inner = block.inner(canvas); + block.render(canvas, buf); + + match &self.content { + PopupContent::Spinner { message, model, .. } => { + self.render_spinner(inner, buf, message, model); + } + PopupContent::Progress { title: _, percent, label } => { + self.render_progress(inner, buf, *percent, label); + } + PopupContent::Password { title: _, prompt } => { + self.render_password(inner, buf, prompt); + } + PopupContent::Info { message, fg, .. } => { + self.render_info(inner, buf, message, *fg); + } + } + + if self.shadow { + self.draw_shadow(canvas, height, buf); + } + } + + fn build_title(&self) -> Line<'static> { + let (title_text, bc) = match &self.content { + PopupContent::Progress { title, .. } => (title.clone(), self.border_color), + PopupContent::Password { title, .. } => (title.clone(), self.border_color), + PopupContent::Info { title, .. } => (title.clone().unwrap_or_default(), self.border_color), + PopupContent::Spinner { .. } => return Line::from(""), + }; + + if title_text.is_empty() { + return Line::from(""); + } + + let text: String = format!(" {} ", title_text); + Line::from(vec![ + Span::styled("\u{E0B2}", Style::default().fg(bc)), + Span::styled(text, Style::default().fg(palette::BLACK).bg(bc)), + Span::styled("\u{E0B0}", Style::default().fg(bc)), + ]) + } + + fn render_gradient(&self, canvas: Rect, buf: &mut Buffer) { + let stops: &[ratatui::style::Color] = &[palette::GRAY_0, palette::BG_2]; + let colors = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, stops); + for row in 0..canvas.height { + for col in 0..canvas.width { + let idx = row as usize * canvas.width as usize + col as usize; + if idx < colors.len() { + if let Some(cell) = buf.cell_mut(Position::new(canvas.x + col, canvas.y + row)) { + cell.set_bg(colors[idx]); + } + } + } + } + } + + fn render_spinner(&self, inner: Rect, buf: &mut Buffer, message: &str, model: &spinner::Model) { + let frame_str = model.view(); + let text = format!("{} {}", frame_str, message); + let text_w = text.len() as u16; + + let [_, mid, _]: [Rect; 3] = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(1), Constraint::Min(0)]) + .split(inner) + .as_ref() + .try_into() + .unwrap(); + + let x = mid.x + (mid.width.saturating_sub(text_w)) / 2; + let text_area = Rect { x, y: mid.y, width: text_w, height: 1 }; + + let mut spans: Vec = vec![frame_str.spans[0].clone(), Span::raw(" ")]; + spans.push(Span::styled(message.to_string(), Style::default().fg(palette::FG))); + Paragraph::new(Line::from(spans)).alignment(Alignment::Left).render(text_area, buf); + } + + fn render_progress(&self, inner: Rect, buf: &mut Buffer, percent: f64, label: &Option) { + let [_, gauge_area, label_area, _]: [Rect; 4] = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0)]) + .split(inner) + .as_ref() + .try_into() + .unwrap(); + + let pct = (percent * 100.0) as u16; + Gauge::default() + .gauge_style(Style::default().fg(palette::ACCENT).bg(palette::BG_3)) + .percent(pct) + .render(gauge_area, buf); + + if let Some(lbl) = label { + let pct_text = format!(" {} {}%", lbl, pct); + let w = pct_text.len() as u16; + let x = label_area.x + (label_area.width.saturating_sub(w)) / 2; + let rect = Rect { x, y: label_area.y, width: w, height: 1 }; + Paragraph::new(pct_text) + .style(Style::default().fg(palette::MUTED)) + .alignment(Alignment::Center) + .render(rect, buf); + } else { + let pct_text = format!("{}%", pct); + let w = pct_text.len() as u16; + let x = label_area.x + (label_area.width.saturating_sub(w)) / 2; + let rect = Rect { x, y: label_area.y, width: w, height: 1 }; + Paragraph::new(pct_text) + .style(Style::default().fg(palette::MUTED)) + .alignment(Alignment::Center) + .render(rect, buf); + } + } + + fn render_password(&self, inner: Rect, buf: &mut Buffer, prompt: &str) { + let [_, prompt_area, input_area, _]: [Rect; 4] = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(1), Constraint::Length(2), Constraint::Min(0)]) + .split(inner) + .as_ref() + .try_into() + .unwrap(); + + let pw = prompt.len() as u16; + let px = prompt_area.x + (prompt_area.width.saturating_sub(pw)) / 2; + Paragraph::new(prompt.to_string()) + .style(Style::default().fg(palette::FG)) + .alignment(Alignment::Center) + .render(Rect { x: px, y: prompt_area.y, width: pw, height: 1 }, buf); + + let mask = "\u{2022}".repeat(12); + let mw = mask.len() as u16; + let mx = input_area.x + (input_area.width.saturating_sub(mw)) / 2; + Paragraph::new(Line::from(vec![ + Span::styled(mask, Style::default().fg(palette::MUTED).bg(palette::BG_3)), + ])) + .alignment(Alignment::Center) + .render(Rect { x: mx, y: input_area.y + 1, width: mw, height: 1 }, buf); + } + + fn render_info(&self, inner: Rect, buf: &mut Buffer, message: &str, fg: ratatui::style::Color) { + let text = format!("\n{}", message); + Paragraph::new(text) + .alignment(Alignment::Center) + .style(Style::default().fg(fg)) + .render(inner, buf); + } + + fn draw_shadow(&self, canvas: Rect, height: u16, buf: &mut Buffer) { + let buf_area = buf.area(); + let max_x = buf_area.right().saturating_sub(1); + let max_y = buf_area.bottom().saturating_sub(1); + + for idx in 0..canvas.width { + let sx = canvas.x.saturating_add(2).saturating_add(idx); + let sy = canvas.y.saturating_add(height); + if sx > max_x || sy > max_y { + continue; + } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + + for offset in 0..2 { + for idx in 0..height { + let sx = canvas.x.saturating_add(canvas.width).saturating_add(offset); + let sy = canvas.y.saturating_add(idx).saturating_add(1); + if sx > max_x || sy > max_y { + continue; + } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + } + } +} + +fn text_lines(text: &str) -> u16 { + let count = text.lines().count(); + if count == 0 { 0 } else { count as u16 } +} From 5bca4ae3ec234f3c627c1db46ae8d9de896e0695 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 29 Jul 2026 17:58:05 +0200 Subject: [PATCH 14/42] Integrate popups (basic) --- Cargo.lock | 1173 ++++++++++++++++++++++++++++++++++++++++++++++++---- Cargo.toml | 3 +- src/tui.rs | 81 ++-- 3 files changed, 1121 insertions(+), 136 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4e6da80..53b33ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -93,12 +102,36 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "arrayvec" version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -111,11 +144,35 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] [[package]] name = "block-buffer" @@ -126,6 +183,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bunkerbox" version = "0.3.0" @@ -134,12 +197,13 @@ dependencies = [ "base64", "clap", "colored", - "crossterm", + "crossterm 0.28.1", "glob", "libc", "pbkdf2", "rand", "ratatui", + "ratatui-glamour", "rpassword", "serde", "serde_json", @@ -152,16 +216,22 @@ dependencies = [ ] [[package]] -name = "bytes" -version = "1.12.1" +name = "by_address" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" [[package]] -name = "cassowary" -version = "0.3.0" +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "bytes" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "castaway" @@ -238,18 +308,28 @@ dependencies = [ [[package]] name = "compact_str" -version = "0.8.2" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" dependencies = [ "castaway", "cfg-if", "itoa", "rustversion", "ryu", + "serde", "static_assertions", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -259,17 +339,43 @@ dependencies = [ "libc", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossterm" version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags", + "bitflags 2.13.1", "crossterm_winapi", "mio", "parking_lot", "rustix 0.38.44", + "serde", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.4", + "serde", "signal-hook", "signal-hook-mio", "winapi", @@ -295,6 +401,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + [[package]] name = "ctr" version = "0.9.2" @@ -324,7 +440,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.118", ] [[package]] @@ -335,7 +451,41 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.118", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.118", ] [[package]] @@ -349,6 +499,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "either" version = "1.16.0" @@ -371,17 +530,71 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + [[package]] name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" -version = "0.1.5" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "futures" @@ -439,7 +652,7 @@ checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -492,6 +705,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -500,7 +725,7 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", ] [[package]] @@ -521,9 +746,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "hashbrown" -version = "0.15.5" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", @@ -535,6 +760,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "heck" @@ -542,6 +772,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hmac" version = "0.12.1" @@ -595,7 +831,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -606,9 +842,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] @@ -619,12 +855,61 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.19", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -637,6 +922,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -654,11 +945,21 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru" -version = "0.12.5" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "mac_address" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" dependencies = [ - "hashbrown 0.15.5", + "nix 0.29.0", + "winapi", ] [[package]] @@ -667,6 +968,12 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + [[package]] name = "memoffset" version = "0.9.1" @@ -676,6 +983,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.2.2" @@ -688,13 +1001,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nix" version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -702,66 +1028,238 @@ dependencies = [ ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "nom" +version = "7.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] [[package]] -name = "once_cell_polyfill" -version = "1.70.2" +name = "num-conv" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] -name = "opaque-debug" -version = "0.3.1" +name = "num-derive" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] [[package]] -name = "parking_lot" -version = "0.12.5" +name = "num-traits" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ - "lock_api", - "parking_lot_core", + "autocfg", ] [[package]] -name = "parking_lot_core" -version = "0.9.12" +name = "num_threads" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" dependencies = [ - "cfg-if", "libc", - "redox_syscall", - "smallvec", - "windows-link", ] [[package]] -name = "paste" -version = "1.0.15" +name = "numtoa" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +checksum = "6aa2c4e539b869820a2b82e1aef6ff40aa85e65decdd5185e83fb4b1249cd00f" [[package]] -name = "pbkdf2" -version = "0.12.2" +name = "once_cell" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" -dependencies = [ - "digest", - "hmac", -] +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "pest" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "pest_meta" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] @@ -776,6 +1274,18 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -803,6 +1313,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -841,23 +1357,126 @@ dependencies = [ [[package]] name = "ratatui" -version = "0.29.0" +version = "0.30.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" dependencies = [ - "bitflags", - "cassowary", - "compact_str", - "crossterm", - "indoc", "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termion", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.1", + "compact_str", + "critical-section", + "hashbrown 0.17.1", "itertools", + "kasuari", "lru", - "paste", + "palette", + "serde", "strum", + "thiserror 2.0.19", "unicode-segmentation", "unicode-truncate", - "unicode-width 0.2.0", + "unicode-width 0.2.2", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm 0.28.1", + "crossterm 0.29.0", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-glamour" +version = "0.2.0" +source = "git+https://github.com/tinythings/ratatui-glamour.git?rev=1545394#1545394cc3481bd5595f5a1f1e1eec3287ccb2a6" +dependencies = [ + "crossterm 0.28.1", + "ratatui", + "unicode-width 0.2.2", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termion" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87c732202fa5a71a9da0991013f0853e53f87048f45198e3a1ca3ee722accc2f" +dependencies = [ + "instability", + "ratatui-core", + "termion", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width 0.2.2", ] [[package]] @@ -866,9 +1485,38 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.1", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "rpassword" version = "7.5.4" @@ -890,13 +1538,22 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -909,7 +1566,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.12.1", @@ -934,6 +1591,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -961,7 +1624,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1032,6 +1695,12 @@ dependencies = [ "libc", ] +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -1068,24 +1737,23 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.26.3" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" dependencies = [ "strum_macros", ] [[package]] name = "strum_macros" -version = "0.26.4" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" dependencies = [ "heck", "proc-macro2", "quote", - "rustversion", - "syn", + "syn 2.0.118", ] [[package]] @@ -1094,6 +1762,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.118" @@ -1105,6 +1784,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -1118,6 +1808,155 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.1", + "parking_lot", + "rustix 1.1.4", + "signal-hook", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termion" +version = "4.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f44138a9ae08f0f502f24104d82517ef4da7330c35acd638f1f29d3cd5475ecb" +dependencies = [ + "libc", + "numtoa", + "serde", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.13.1", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "serde", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + [[package]] name = "tokio" version = "1.53.0" @@ -1142,7 +1981,7 @@ checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1164,6 +2003,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1178,13 +2023,13 @@ checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-truncate" -version = "1.1.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ "itertools", "unicode-segmentation", - "unicode-width 0.1.14", + "unicode-width 0.2.2", ] [[package]] @@ -1195,9 +2040,9 @@ checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unicode-width" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "universal-hash" @@ -1221,6 +2066,19 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "atomic", + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "version_check" version = "0.9.5" @@ -1234,7 +2092,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ba782755fc073877e567c2253c0be48e4aa9a254c232d36d3985dfae0bd5205" dependencies = [ "libc", - "nix", + "nix 0.31.3", ] [[package]] @@ -1270,12 +2128,149 @@ dependencies = [ "quote", ] +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.118", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "serde", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1386,6 +2381,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "zerocopy" version = "0.8.54" @@ -1403,7 +2404,7 @@ checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8ef654b..c6fb512 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,9 +19,10 @@ serde_yaml = "0.9" sha2 = "0.10" tokio = { version = "1", features = ["rt-multi-thread", "net", "macros", "sync", "io-util", "process"] } tokio-vsock = "0.7" -ratatui = "0.29" +ratatui = "0.30" crossterm = "0.28" vt100 = "0.15" +ratatui-glamour = { git = "https://github.com/tinythings/ratatui-glamour.git", rev = "1545394" } [dev-dependencies] tempfile = "3" diff --git a/src/tui.rs b/src/tui.rs index 33a754c..7856850 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -15,6 +15,10 @@ use ratatui::Terminal; use crate::vscomm::{self, parse_triggers, Trigger}; +mod palette; +mod popup; +use popup::PopupWidget; + static RESIZED: AtomicBool = AtomicBool::new(false); extern "C" fn handle_sigwinch(_: libc::c_int) { @@ -24,6 +28,8 @@ extern "C" fn handle_sigwinch(_: libc::c_int) { const WIDGET_PROGRESS: &str = "progress"; const WIDGET_STATUS: &str = "status"; const WIDGET_POPUP: &str = "popup"; +const WIDGET_SPINNER: &str = "spinner"; +const WIDGET_PASSWORD: &str = "password"; const CMD_SHOW: &str = "show"; const CMD_HIDE: &str = "hide"; @@ -41,15 +47,13 @@ pub struct PendingAction { pub struct OverlayState { pub status_text: String, - pub progress_percent: Option, - pub progress_label: Option, - pub popup_text: Option, + pub popup: PopupWidget, pub pending: Vec, } impl OverlayState { pub fn new(status_text: String) -> Self { - Self { status_text, progress_percent: None, progress_label: None, popup_text: None, pending: Vec::new() } + Self { status_text, popup: PopupWidget::new(), pending: Vec::new() } } } @@ -70,22 +74,19 @@ pub fn dispatch_ui_command(state: &mut OverlayState, widget: &str, command: &str match (widget, command) { (WIDGET_PROGRESS, CMD_SET) => { if let Ok(pct) = value.parse::() { - state.progress_percent = Some(pct.clamp(0.0, 1.0)); + state.popup.set_progress(pct.clamp(0.0, 1.0), None); } } (WIDGET_PROGRESS, CMD_SHOW) => { if let Some((pct, label)) = value.split_once(';') { - if let Ok(pct) = pct.trim().parse::() { - state.progress_percent = Some(pct.clamp(0.0, 1.0)); - } - state.progress_label = Some(label.trim().to_string()); + let pct: f64 = pct.trim().parse().unwrap_or(0.0); + state.popup.show_progress("", pct.clamp(0.0, 1.0), Some(label.trim().to_string())); } else if let Ok(pct) = value.parse::() { - state.progress_percent = Some(pct.clamp(0.0, 1.0)); + state.popup.show_progress("", pct.clamp(0.0, 1.0), None); } } (WIDGET_PROGRESS, CMD_HIDE) => { - state.progress_percent = None; - state.progress_label = None; + state.popup.hide(); } (WIDGET_STATUS, CMD_SET) => { state.status_text = value.to_string(); @@ -94,10 +95,22 @@ pub fn dispatch_ui_command(state: &mut OverlayState, widget: &str, command: &str state.status_text.clear(); } (WIDGET_POPUP, CMD_SHOW) => { - state.popup_text = Some(value.to_string()); + state.popup.show_info(None, value, None, None); } (WIDGET_POPUP, CMD_HIDE) => { - state.popup_text = None; + state.popup.hide(); + } + (WIDGET_SPINNER, CMD_SHOW) => { + state.popup.show_spinner(value); + } + (WIDGET_SPINNER, CMD_HIDE) => { + state.popup.hide(); + } + (WIDGET_PASSWORD, CMD_SHOW) => { + state.popup.show_password(options, value); + } + (WIDGET_PASSWORD, CMD_HIDE) => { + state.popup.hide(); } _ => {} } @@ -434,6 +447,8 @@ where } } + state.popup.tick(); + terminal.draw(|f| render_frame(f, term.screen(), &state)).map_err(|e| format!("draw: {e}"))?; } } @@ -523,41 +538,9 @@ fn render_frame(f: &mut Frame, screen: &vt100::Screen, overlay: &OverlayState) { } } - if let Some(ref text) = overlay.popup_text { - let popup_w = (text.len() as u16 + 4).min(area.width.saturating_sub(4)); - let popup_h = 3u16; - let popup_x = (area.width.saturating_sub(popup_w)) / 2; - let popup_y = (area.height.saturating_sub(popup_h)) / 2; - let rect = Rect::new(popup_x, popup_y, popup_w, popup_h); - f.render_widget(Clear, rect); - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Yellow)) - .title(format!(" {} ", text)) - .title_style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD)); - f.render_widget(block, rect); - } - - if let Some(pct) = overlay.progress_percent { - let bar_w = (area.width.saturating_sub(8)).min(60); - let bar_x = (area.width.saturating_sub(bar_w)) / 2; - let bar_y = area.height.saturating_sub(4); - let rect = Rect::new(bar_x, bar_y, bar_w, 3u16); - f.render_widget(Clear, rect); - - let pct_label = format!(" {:.0}% ", pct * 100.0); - let label = overlay.progress_label.as_deref().unwrap_or(""); - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)) - .title(format!(" {label} {pct_label}")) - .title_style(Style::default().fg(Color::White)); - f.render_widget(block, rect); - - let gauge = - ratatui::widgets::Gauge::default().gauge_style(Style::default().fg(Color::Cyan).bg(Color::DarkGray)).percent((pct * 100.0) as u16); - let inner = Rect::new(bar_x + 1, bar_y + 1, bar_w.saturating_sub(2), 1); - f.render_widget(gauge, inner); + { + let buf = f.buffer_mut(); + overlay.popup.render(area, buf); } if !overlay.status_text.is_empty() { From 4d6a6ae3c702e65b02d4a14fbc9765642628526d Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 29 Jul 2026 18:10:49 +0200 Subject: [PATCH 15/42] Add first version of loading TUI --- src/kata.rs | 26 ++++++++++++------- src/main.rs | 2 +- src/tui.rs | 45 ++++++++++++++++----------------- src/tui/palette.rs | 1 - src/tui/popup.rs | 63 +++++++++++++--------------------------------- 5 files changed, 57 insertions(+), 80 deletions(-) diff --git a/src/kata.rs b/src/kata.rs index 5e80f48..52e47a4 100644 --- a/src/kata.rs +++ b/src/kata.rs @@ -40,6 +40,16 @@ pub fn run( } } + fn write_ui_cmd(fd: RawFd, widget: &str, command: &str, options: &str, value: &str) { + let payload = crate::vscomm::encode_ui_payload(widget, command, options, value); + let mut buf = b"@".to_vec(); + buf.extend_from_slice(&payload); + buf.push(b'\n'); + unsafe { + libc::write(fd, buf.as_ptr() as *const libc::c_void, buf.len()); + } + } + if !config.oci.is_file() { return Err(format!("OCI archive not found: {}", config.oci.display())); } @@ -48,6 +58,8 @@ pub fn run( let home_path = (config.home == Some(HomeMode::Persist)).then(|| config.home_path.clone().unwrap_or_else(|| default_home_path(app_name))); + write_ui_cmd(status_fd, "status", "phase", "", "Start"); + write_status(status_fd, "Preparing runtime..."); let needs_bridge = config.network == Some(NetworkMode::Bridge); @@ -221,20 +233,16 @@ pub fn run( write_status(status_fd, "Starting container..."); + write_ui_cmd(status_fd, "status", "phase", "", "Environment"); + write_status(status_fd, "Connected"); - { - let payload = crate::vscomm::encode_ui_payload("status", "clear", "ON_PTY,SEC_3", ""); - let mut buf = b"@".to_vec(); - buf.extend_from_slice(&payload); - buf.push(b'\n'); - unsafe { - libc::write(status_fd, buf.as_ptr() as *const libc::c_void, buf.len()); - } - } + write_ui_cmd(status_fd, "status", "clear", "ON_PTY,SEC_6", ""); let result = run_command("sudo", &args); + write_ui_cmd(status_fd, "status", "phase", "", "Shut down"); + write_status(status_fd, "Container stopped"); if bridge_firewall { let _ = remove_bridge_egress_firewall(); diff --git a/src/main.rs b/src/main.rs index 5e28f36..ffb0821 100644 --- a/src/main.rs +++ b/src/main.rs @@ -216,7 +216,7 @@ fn run_packaged_runtime(config: cfg::RuntimeConfig, workspace_override: Option> = Arc::new(Mutex::new(tui::OverlayState::new("Starting...".into()))); + let overlay: Arc> = Arc::new(Mutex::new(tui::OverlayState::new())); let overlay_clone = overlay.clone(); let tui_result = tui::event_loop( diff --git a/src/tui.rs b/src/tui.rs index 7856850..5607d2e 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -10,7 +10,7 @@ use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; use crossterm::ExecutableCommand; use ratatui::backend::CrosstermBackend; use ratatui::prelude::*; -use ratatui::widgets::{Block, Borders, Clear}; + use ratatui::Terminal; use crate::vscomm::{self, parse_triggers, Trigger}; @@ -48,12 +48,21 @@ pub struct PendingAction { pub struct OverlayState { pub status_text: String, pub popup: PopupWidget, + pub popup_title: Option, pub pending: Vec, } +impl Default for OverlayState { + fn default() -> Self { + Self::new() + } +} + impl OverlayState { - pub fn new(status_text: String) -> Self { - Self { status_text, popup: PopupWidget::new(), pending: Vec::new() } + pub fn new() -> Self { + let mut popup = PopupWidget::new(); + popup.show_info(Some("Start".into()), "Starting...", Some(palette::FG), Some(palette::ACCENT)); + Self { status_text: String::new(), popup, popup_title: Some("Start".into()), pending: Vec::new() } } } @@ -88,11 +97,16 @@ pub fn dispatch_ui_command(state: &mut OverlayState, widget: &str, command: &str (WIDGET_PROGRESS, CMD_HIDE) => { state.popup.hide(); } + (WIDGET_STATUS, "phase") => { + state.popup_title = if value.is_empty() { None } else { Some(value.to_string()) }; + } (WIDGET_STATUS, CMD_SET) => { - state.status_text = value.to_string(); + let title = state.popup_title.clone(); + state.popup.show_info(title, value, Some(palette::FG), Some(palette::ACCENT)); } (WIDGET_STATUS, CMD_CLEAR) => { - state.status_text.clear(); + state.popup.hide(); + state.popup_title = None; } (WIDGET_POPUP, CMD_SHOW) => { state.popup.show_info(None, value, None, None); @@ -409,7 +423,9 @@ where dispatch_ui_command(&mut state, widget, cmd, opts, val); } } else { - overlay.lock().unwrap().status_text = line; + let mut state = overlay.lock().unwrap(); + let title = state.popup_title.clone(); + state.popup.show_info(title, &line, Some(palette::FG), Some(palette::ACCENT)); } } } @@ -543,23 +559,6 @@ fn render_frame(f: &mut Frame, screen: &vt100::Screen, overlay: &OverlayState) { overlay.popup.render(area, buf); } - if !overlay.status_text.is_empty() { - let title = format!(" {} ", overlay.status_text); - let win_w = (title.len() as u16).max(15); - let win_h = 3u16; - let win_x = area.width.saturating_sub(win_w + 4); - let win_y = 2; - let rect = Rect::new(win_x, win_y, win_w, win_h); - - f.render_widget(Clear, rect); - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)) - .title(title) - .title_style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD)); - f.render_widget(block, rect); - } - let (cursor_row, cursor_col) = screen.cursor_position(); if cursor_row < max_rows && cursor_col < max_cols { f.set_cursor_position((area.x + cursor_col, area.y + cursor_row)); diff --git a/src/tui/palette.rs b/src/tui/palette.rs index c08719e..275dbaa 100644 --- a/src/tui/palette.rs +++ b/src/tui/palette.rs @@ -5,7 +5,6 @@ use ratatui::style::Color; // --------------------------------------------------------------------------- // ---- Neutral foundation ---- - pub const BG_0: Color = Color::Indexed(232); pub const BG_1: Color = Color::Indexed(233); pub const BG_2: Color = Color::Indexed(235); diff --git a/src/tui/popup.rs b/src/tui/popup.rs index abf48b6..23f79bc 100644 --- a/src/tui/popup.rs +++ b/src/tui/popup.rs @@ -13,25 +13,10 @@ use super::palette; const SPINNER_FPS_MS: u64 = 83; pub enum PopupContent { - Spinner { - message: String, - model: spinner::Model, - last_tick: Instant, - }, - Progress { - title: String, - percent: f64, - label: Option, - }, - Password { - title: String, - prompt: String, - }, - Info { - title: Option, - message: String, - fg: ratatui::style::Color, - }, + Spinner { message: String, model: spinner::Model, last_tick: Instant }, + Progress { title: String, percent: f64, label: Option }, + Password { title: String, prompt: String }, + Info { title: Option, message: String, fg: ratatui::style::Color }, } pub struct PopupWidget { @@ -45,11 +30,7 @@ impl PopupWidget { pub fn new() -> Self { Self { visible: false, - content: PopupContent::Info { - title: None, - message: String::new(), - fg: palette::FG, - }, + content: PopupContent::Info { title: None, message: String::new(), fg: palette::FG }, border_color: palette::BORDER, shadow: true, } @@ -247,29 +228,20 @@ impl PopupWidget { .unwrap(); let pct = (percent * 100.0) as u16; - Gauge::default() - .gauge_style(Style::default().fg(palette::ACCENT).bg(palette::BG_3)) - .percent(pct) - .render(gauge_area, buf); + Gauge::default().gauge_style(Style::default().fg(palette::ACCENT).bg(palette::BG_3)).percent(pct).render(gauge_area, buf); if let Some(lbl) = label { let pct_text = format!(" {} {}%", lbl, pct); let w = pct_text.len() as u16; let x = label_area.x + (label_area.width.saturating_sub(w)) / 2; let rect = Rect { x, y: label_area.y, width: w, height: 1 }; - Paragraph::new(pct_text) - .style(Style::default().fg(palette::MUTED)) - .alignment(Alignment::Center) - .render(rect, buf); + Paragraph::new(pct_text).style(Style::default().fg(palette::MUTED)).alignment(Alignment::Center).render(rect, buf); } else { let pct_text = format!("{}%", pct); let w = pct_text.len() as u16; let x = label_area.x + (label_area.width.saturating_sub(w)) / 2; let rect = Rect { x, y: label_area.y, width: w, height: 1 }; - Paragraph::new(pct_text) - .style(Style::default().fg(palette::MUTED)) - .alignment(Alignment::Center) - .render(rect, buf); + Paragraph::new(pct_text).style(Style::default().fg(palette::MUTED)).alignment(Alignment::Center).render(rect, buf); } } @@ -292,19 +264,14 @@ impl PopupWidget { let mask = "\u{2022}".repeat(12); let mw = mask.len() as u16; let mx = input_area.x + (input_area.width.saturating_sub(mw)) / 2; - Paragraph::new(Line::from(vec![ - Span::styled(mask, Style::default().fg(palette::MUTED).bg(palette::BG_3)), - ])) - .alignment(Alignment::Center) - .render(Rect { x: mx, y: input_area.y + 1, width: mw, height: 1 }, buf); + Paragraph::new(Line::from(vec![Span::styled(mask, Style::default().fg(palette::MUTED).bg(palette::BG_3))])) + .alignment(Alignment::Center) + .render(Rect { x: mx, y: input_area.y + 1, width: mw, height: 1 }, buf); } fn render_info(&self, inner: Rect, buf: &mut Buffer, message: &str, fg: ratatui::style::Color) { let text = format!("\n{}", message); - Paragraph::new(text) - .alignment(Alignment::Center) - .style(Style::default().fg(fg)) - .render(inner, buf); + Paragraph::new(text).alignment(Alignment::Center).style(Style::default().fg(fg)).render(inner, buf); } fn draw_shadow(&self, canvas: Rect, height: u16, buf: &mut Buffer) { @@ -342,5 +309,9 @@ impl PopupWidget { fn text_lines(text: &str) -> u16 { let count = text.lines().count(); - if count == 0 { 0 } else { count as u16 } + if count == 0 { + 0 + } else { + count as u16 + } } From 4e6318958026339bb1d79f5762775f37aa43a3f7 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 29 Jul 2026 20:00:44 +0200 Subject: [PATCH 16/42] Add logging, fix autosizer --- src/cfg.rs | 8 +++- src/kata.rs | 110 ++++++++++++++++++++++++++++++------------------- src/lib.rs | 1 + src/logging.rs | 42 +++++++++++++++++++ src/main.rs | 13 ++++++ src/overlay.rs | 16 +++++-- src/tui.rs | 30 ++++++++++++-- 7 files changed, 170 insertions(+), 50 deletions(-) create mode 100644 src/logging.rs diff --git a/src/cfg.rs b/src/cfg.rs index eb17e20..f8da475 100644 --- a/src/cfg.rs +++ b/src/cfg.rs @@ -25,15 +25,21 @@ const DEFAULT_EXCLUDE: &[&str] = &[ const DEFAULT_SESSION_CLEANUP: &[&str] = &[ ".bunker", ".npm", + ".cache", + ".cargo/registry", + ".cargo/git", + ".rustup", ".config/kilo/node_modules", + ".config/kilo/target", ".config/opencode/node_modules", + ".config/opencode/target", ".config/crush/node_modules", + ".config/crush/target", ".local/share/kilo/log", ".local/share/opencode/log", ".local/share/crush/log", ".local/share/kilo/snapshot", ".local/share/kilo/storage/session_diff", - ".cache", ]; const MIN_QUOTA: u64 = 5 * 1024 * 1024 * 1024; diff --git a/src/kata.rs b/src/kata.rs index 52e47a4..acc9549 100644 --- a/src/kata.rs +++ b/src/kata.rs @@ -28,18 +28,20 @@ const BRIDGE_NAME: &str = "bunkerbox0"; /// /// # Returns /// `Ok(())` on successful container execution, `Err(String)` on failure. +fn cleanup_partial_session(session_dir: Option<&PathBuf>, home_path: Option<&PathBuf>) { + if let (Some(sd), Some(hp)) = (session_dir, home_path) { + let session_img = hp.parent().expect("home has parent").join(".bunker").join("session.img"); + crate::logging::log("cleaning up partial session..."); + let _ = run_command_allow_failure("sudo", &["umount", &format!("{}", sd.display())]); + let _ = run_command_allow_failure("sudo", &["rmdir", &format!("{}", sd.display())]); + let _ = run_command_allow_failure("rm", &["-f", &format!("{}", session_img.display())]); + } +} + pub fn run( config: &RuntimeConfig, workspace: WorkspaceHandle, container_name: &str, _share_dir: &Path, app_name: &str, vsock_enabled: bool, status_fd: RawFd, ) -> Result<(), String> { - fn write_status(fd: RawFd, msg: &str) { - let mut buf = msg.as_bytes().to_vec(); - buf.push(b'\n'); - unsafe { - libc::write(fd, buf.as_ptr() as *const libc::c_void, buf.len()); - } - } - fn write_ui_cmd(fd: RawFd, widget: &str, command: &str, options: &str, value: &str) { let payload = crate::vscomm::encode_ui_payload(widget, command, options, value); let mut buf = b"@".to_vec(); @@ -58,9 +60,7 @@ pub fn run( let home_path = (config.home == Some(HomeMode::Persist)).then(|| config.home_path.clone().unwrap_or_else(|| default_home_path(app_name))); - write_ui_cmd(status_fd, "status", "phase", "", "Start"); - - write_status(status_fd, "Preparing runtime..."); + crate::logging::log("Preparing runtime..."); let needs_bridge = config.network == Some(NetworkMode::Bridge); let ctr_name = container_name.to_string(); @@ -75,7 +75,7 @@ pub fn run( run_command_allow_failure("sudo", &["mkdir", "-p", &format!("{}", runtime_dir.display())])?; run_command_quiet("sudo", &["chown", &user, &format!("{}", runtime_dir.display())])?; - write_status(status_fd, "Starting containerd..."); + crate::logging::log("Starting containerd..."); let setup_handle = std::thread::spawn(move || -> Result<(), String> { let active = Command::new("sudo") @@ -114,7 +114,7 @@ pub fn run( if !passphrase.is_empty() { if let Some(ref hp) = home_path { if hp.is_dir() { - write_status(status_fd, "Unsealing home..."); + crate::logging::log("Unsealing home..."); unseal_home(hp, &passphrase)?; } } @@ -122,15 +122,24 @@ pub fn run( let cleanup_patterns = config.effective_session_cleanup(); if let Some(ref hp) = home_path { - eprintln!("bunkerbox: cleaning up home directory..."); - write_status(status_fd, "Cleaning home..."); + crate::logging::log("Cleaning home..."); cleanup_home_junk(hp, &cleanup_patterns); } - let session_mb = config.session_mb(); + let mut session_mb = config.session_mb(); let session_dir: Option = if session_mb > 0 { if let Some(ref hp) = home_path { - write_status(status_fd, "Setting up session image..."); + let home_size = dir_size(hp); + let measured_mb = ((home_size as f64 * 1.2) / (1024.0 * 1024.0)).ceil() as u32; + if config.session_mb.is_none() || measured_mb > session_mb { + session_mb = measured_mb.max(50); + crate::logging::log(&format!( + "home dir is {} MB, auto-sized session to {} MB", + home_size / (1024 * 1024), + session_mb + )); + } + crate::logging::log("Setting up session image..."); Some(setup_session(hp, session_mb, uid, gid)?) } else { None @@ -141,20 +150,26 @@ pub fn run( match setup_handle.join() { Ok(Ok(())) => {} - Ok(Err(e)) => return Err(e), - Err(_) => return Err("setup thread panicked".to_string()), + Ok(Err(e)) => { + cleanup_partial_session(session_dir.as_ref(), home_path.as_ref()); + return Err(e); + } + Err(_) => { + cleanup_partial_session(session_dir.as_ref(), home_path.as_ref()); + return Err("setup thread panicked".to_string()); + } } let bridge_firewall = config.network == Some(NetworkMode::Bridge) && config.allow.is_some(); let resolv_conf = if config.network.is_some() { - write_status(status_fd, "Writing resolv.conf..."); + crate::logging::log("Writing resolv.conf..."); Some(write_resolv_conf()?) } else { None }; if bridge_firewall { - write_status(status_fd, "Applying firewall rules..."); + crate::logging::log("Applying firewall rules..."); ensure_bridge_egress_firewall(config, resolv_conf.as_deref())?; } let resolv_conf_mount = resolv_conf.as_ref().map(|path| format!("type=bind,src={},dst=/etc/resolv.conf,options=rbind:ro", path.display())); @@ -231,19 +246,15 @@ pub fn run( args.push(&config.image); args.push(container_name); - write_status(status_fd, "Starting container..."); - - write_ui_cmd(status_fd, "status", "phase", "", "Environment"); + crate::logging::log("Starting container..."); - write_status(status_fd, "Connected"); + crate::logging::log("Connected"); - write_ui_cmd(status_fd, "status", "clear", "ON_PTY,SEC_6", ""); + write_ui_cmd(status_fd, "popup", "hide", "ON_PTY,SEC_6", ""); let result = run_command("sudo", &args); - write_ui_cmd(status_fd, "status", "phase", "", "Shut down"); - - write_status(status_fd, "Container stopped"); + crate::logging::log("Container stopped"); if bridge_firewall { let _ = remove_bridge_egress_firewall(); } @@ -254,7 +265,7 @@ pub fn run( if let Some(ref sd) = session_dir { if let Some(ref hp) = home_path { - write_status(status_fd, "Saving session..."); + crate::logging::log("Saving session..."); let cleanup = config.effective_session_cleanup(); teardown_session(hp, sd, &cleanup); } @@ -263,7 +274,7 @@ pub fn run( if !passphrase.is_empty() { if let Some(ref hp) = home_path { if hp.is_dir() { - write_status(status_fd, "Sealing home..."); + crate::logging::log("Sealing home..."); seal_home(hp, encrypt_patterns, &passphrase)?; } } @@ -503,9 +514,17 @@ fn ensure_bridge_cni_config() -> Result<(), String> { /// Force-kills and removes a containerd task/container and its CNI state if left over from a prior run. fn remove_stale_container(container_name: &str) -> Result<(), String> { - let _ = run_command_allow_failure("sudo", &["ctr", "tasks", "kill", "--signal", "SIGKILL", container_name]); - let _ = run_command_allow_failure("sudo", &["ctr", "tasks", "delete", "--force", container_name]); - let _ = run_command_allow_failure("sudo", &["ctr", "containers", "rm", container_name]); + crate::logging::log(&format!("remove_stale: checking for stale container '{container_name}'")); + + if run_command_allow_failure("sudo", &["ctr", "tasks", "kill", "--signal", "SIGKILL", container_name]).is_ok() { + crate::logging::log(&format!("remove_stale: killed stale task for '{container_name}'")); + } + if run_command_allow_failure("sudo", &["ctr", "tasks", "delete", "--force", container_name]).is_ok() { + crate::logging::log(&format!("remove_stale: deleted stale task for '{container_name}'")); + } + if run_command_allow_failure("sudo", &["ctr", "containers", "rm", container_name]).is_ok() { + crate::logging::log(&format!("remove_stale: removed stale container '{container_name}'")); + } let _ = run_command_allow_failure("sudo", &["rm", "-f", &format!("/var/lib/cni/networks/{BRIDGE_NAME}/{container_name}")]); Ok(()) } @@ -716,12 +735,12 @@ fn seal_home(home: &Path, patterns: &[String], passphrase: &str) -> Result<(), S /// Creates and mounts an ext4 session image, copies the home contents, and recovers any leftover image. fn setup_session(home_path: &Path, session_mb: u32, uid: &str, gid: &str) -> Result { - let bunker_dir = home_path.join(".bunker"); + let bunker_dir = home_path.parent().expect("home has parent").join(".bunker"); let session_img = bunker_dir.join("session.img"); let session_dir = home_path.parent().ok_or("home path has no parent directory")?.join("session-mount"); if session_img.exists() { - eprintln!("bunkerbox: found leftover session.img, recovering..."); + crate::logging::log("found leftover session.img, recovering..."); let _ = run_command_allow_failure("sudo", &["mkdir", "-p", session_dir.to_str().unwrap()]); let _ = run_command_quiet("sudo", &["e2fsck", "-p", &format!("{}", session_img.display())]); @@ -732,16 +751,17 @@ fn setup_session(home_path: &Path, session_mb: u32, uid: &str, gid: &str) -> Res let _ = run_command_allow_failure("sudo", &["rm", "-rf", &format!("{}/lost+found", session_dir.display())]); let _ = run_command_allow_failure("sudo", &["umount", &format!("{}", session_dir.display())]); } else { - eprintln!("bunkerbox: warning: could not mount leftover session.img, discarding"); + crate::logging::log("warning: could not mount leftover session.img, discarding"); } let _ = run_command_allow_failure("sudo", &["rm", "-rf", &format!("{}", session_dir.display())]); let _ = run_command_allow_failure("rm", &["-f", &format!("{}", session_img.display())]); } - eprintln!("bunkerbox: setting up session..."); + crate::logging::log("setting up session..."); - run_command_allow_failure("mkdir", &["-p", &format!("{}", bunker_dir.display())])?; + run_command_quiet("sudo", &["mkdir", "-p", &format!("{}", bunker_dir.display())])?; + run_command_quiet("sudo", &["chown", &format!("{}:{}", uid, gid), &format!("{}", bunker_dir.display())])?; run_command_quiet("dd", &["if=/dev/zero", &format!("of={}", session_img.display()), "bs=1M", &format!("count={}", session_mb)])?; run_command_quiet("mke2fs", &["-F", "-t", "ext4", &format!("{}", session_img.display())])?; run_command_allow_failure("sudo", &["mkdir", "-p", &format!("{}", session_dir.display())])?; @@ -752,7 +772,11 @@ fn setup_session(home_path: &Path, session_mb: u32, uid: &str, gid: &str) -> Res let home_size = dir_size(home_path); let max_size = (session_mb as u64) * 1024 * 1024; if home_size > max_size { - eprintln!("bunkerbox: warning: home directory is {} MB but session is only {} MB - copy may fail", home_size / (1024 * 1024), session_mb); + crate::logging::log(&format!( + "warning: home directory ({} MB) exceeds session image ({} MB) — copy may fail", + home_size / (1024 * 1024), + session_mb + )); } let cp_status = run_command_quiet("cp", &["-a", &format!("{}/.", home_path.display()), &format!("{}/", session_dir.display())]); @@ -768,14 +792,14 @@ fn setup_session(home_path: &Path, session_mb: u32, uid: &str, gid: &str) -> Res /// Copies session changes back to home, unmounts, and removes the session image and mount point. fn teardown_session(home_path: &Path, session_dir: &Path, cleanup_patterns: &[String]) { - eprintln!("bunkerbox: preparing to persist session..."); + crate::logging::log("preparing to persist session..."); cleanup_home_junk(session_dir, cleanup_patterns); let _ = run_command_quiet("cp", &["-Rup", &format!("{}/.", session_dir.display()), &format!("{}", home_path.display())]); let _ = run_command_allow_failure("sudo", &["umount", &format!("{}", session_dir.display())]); - let session_img = home_path.join(".bunker").join("session.img"); + let session_img = home_path.parent().expect("home has parent").join(".bunker").join("session.img"); let _ = run_command_allow_failure("rm", &["-f", &format!("{}", session_img.display())]); let _ = run_command_allow_failure("sudo", &["rmdir", &format!("{}", session_dir.display())]); } @@ -864,7 +888,7 @@ fn cleanup_home_junk(path: &Path, patterns: &[String]) { for pattern in patterns { let target = path.join(pattern); if target.exists() { - eprintln!("bunkerbox: purging {pattern}"); + crate::logging::log(&format!("purging {pattern}")); let _ = run_command_allow_failure("rm", &["-rf", &format!("{}", target.display())]); } } diff --git a/src/lib.rs b/src/lib.rs index 782c28e..5e04a3f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod clidef; pub mod cmdrun; pub mod daemon; pub mod kata; +pub mod logging; pub mod overlay; pub mod proxy; pub mod sandbox; diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 0000000..8d9230d --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,42 @@ +use std::cell::RefCell; +use std::fs::OpenOptions; +use std::io::Write; +use std::os::unix::io::RawFd; + +const LOG_PATH: &str = "/tmp/bunkerbox.log"; + +thread_local! { + static STATUS_FD: RefCell> = const { RefCell::new(None) }; +} + +pub fn log_path() -> &'static str { + LOG_PATH +} + +pub fn set_status_fd(fd: RawFd) { + STATUS_FD.with(|f| *f.borrow_mut() = Some(fd)); +} + +pub fn log(msg: &str) { + let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs(); + let line = format!("[{ts}] {msg}\n"); + + eprint!("[bb] {line}"); + + if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(LOG_PATH) { + let _ = f.write_all(line.as_bytes()); + } + + STATUS_FD.with(|f| { + if let Some(fd) = *f.borrow() { + let title = format!("Log: {LOG_PATH}"); + let payload = crate::vscomm::encode_ui_payload("popup", "info", &title, msg); + let mut buf = b"@".to_vec(); + buf.extend_from_slice(&payload); + buf.push(b'\n'); + unsafe { + libc::write(fd, buf.as_ptr() as *const libc::c_void, buf.len()); + } + } + }); +} diff --git a/src/main.rs b/src/main.rs index ffb0821..2f0abfc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -192,6 +192,8 @@ fn run_packaged_runtime(config: cfg::RuntimeConfig, workspace_override: Option 0, Err(e) => { eprintln!("bunkerbox: {e}"); + + let msg = e.to_string(); + let payload = bunkerbox::vscomm::encode_ui_payload("popup", "info", "Error", &msg); + let mut buf = b"@".to_vec(); + buf.extend_from_slice(&payload); + buf.push(b'\n'); + unsafe { + libc::write(status_fd, buf.as_ptr() as *const libc::c_void, buf.len()); + } + std::thread::sleep(std::time::Duration::from_millis(500)); + 1 } }; diff --git a/src/overlay.rs b/src/overlay.rs index f04de35..12c9b88 100644 --- a/src/overlay.rs +++ b/src/overlay.rs @@ -131,12 +131,22 @@ impl CowWorkspace { /// Unmounts stale overlay and loop mounts from previous runs, then detaches the loop device. fn cleanup_stale(mount_point: &Path, loop_mount: &Path, loopback: &Path) -> Result<(), String> { - Self::run_command_allow_failure("umount", &[&mount_point.to_string_lossy()])?; + match Self::run_command_allow_failure("umount", &[&mount_point.to_string_lossy()]) { + Ok(()) => crate::logging::log(&format!("cleanup_stale: unmounted overlay {}", mount_point.display())), + Err(e) => crate::logging::log(&format!("cleanup_stale: WARNING overlay umount {} failed: {e}", mount_point.display())), + } - Self::run_command_allow_failure("umount", &[&loop_mount.to_string_lossy()])?; + match Self::run_command_allow_failure("umount", &[&loop_mount.to_string_lossy()]) { + Ok(()) => crate::logging::log(&format!("cleanup_stale: unmounted loop mount {}", loop_mount.display())), + Err(e) => crate::logging::log(&format!("cleanup_stale: WARNING loop umount {} failed: {e}", loop_mount.display())), + } if loopback.exists() { - Self::run_command_allow_failure("losetup", &["-d", &Self::find_loop_device(loopback).unwrap_or_default()])?; + let dev = Self::find_loop_device(loopback).unwrap_or_default(); + match Self::run_command_allow_failure("losetup", &["-d", &dev]) { + Ok(()) => crate::logging::log(&format!("cleanup_stale: detached loop device {dev}")), + Err(e) => crate::logging::log(&format!("cleanup_stale: WARNING losetup -d {dev} failed: {e}")), + } } Ok(()) diff --git a/src/tui.rs b/src/tui.rs index 5607d2e..3914977 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -6,6 +6,7 @@ use std::sync::{Arc, Mutex}; use std::time::Instant; use crossterm::cursor; +use crossterm::event::{self, Event}; use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; use crossterm::ExecutableCommand; use ratatui::backend::CrosstermBackend; @@ -60,9 +61,7 @@ impl Default for OverlayState { impl OverlayState { pub fn new() -> Self { - let mut popup = PopupWidget::new(); - popup.show_info(Some("Start".into()), "Starting...", Some(palette::FG), Some(palette::ACCENT)); - Self { status_text: String::new(), popup, popup_title: Some("Start".into()), pending: Vec::new() } + Self { status_text: String::new(), popup: PopupWidget::new(), popup_title: None, pending: Vec::new() } } } @@ -111,6 +110,10 @@ pub fn dispatch_ui_command(state: &mut OverlayState, widget: &str, command: &str (WIDGET_POPUP, CMD_SHOW) => { state.popup.show_info(None, value, None, None); } + (WIDGET_POPUP, "info") => { + let title = if options.is_empty() { None } else { Some(options.to_string()) }; + state.popup.show_info(title, value, Some(palette::FG), Some(palette::ACCENT)); + } (WIDGET_POPUP, CMD_HIDE) => { state.popup.hide(); } @@ -311,6 +314,26 @@ fn push_dec_special_graphic(out: &mut Vec, byte: u8) { out.extend_from_slice(mapped.encode_utf8(&mut buf).as_bytes()); } +fn hold_error_popup(overlay: &Arc>, term: &mut Terminal>, screen: &vt100::Screen) { + let deadline = Instant::now() + std::time::Duration::from_secs(30); + loop { + { + let state = overlay.lock().unwrap(); + term.draw(|f| render_frame(f, screen, &state)).ok(); + } + + if Instant::now() >= deadline { + break; + } + + if event::poll(std::time::Duration::from_millis(200)).unwrap_or(false) { + if let Ok(Event::Key(_)) = event::read() { + break; + } + } + } +} + /// Renders PTY output through ratatui until the child process exits. /// /// Forwards real keystrokes to the PTY master, parses terminal output through @@ -393,6 +416,7 @@ where term.process(&pty_buf[..n as usize]); pty_output = true; } else { + hold_error_popup(&overlay, &mut terminal, term.screen()); break; } } From 36bffc41286b62e6840876aa491aa7b397b4dca6 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 29 Jul 2026 21:12:00 +0200 Subject: [PATCH 17/42] Make a better logging, log everything and display --- src/kata.rs | 46 ++++++++++++++++++++++++++++++---------------- src/main.rs | 3 ++- src/overlay.rs | 40 ++++++++++++++++++++++++++-------------- src/tui.rs | 32 +++++++++++++++----------------- src/tui/palette.rs | 2 ++ 5 files changed, 75 insertions(+), 48 deletions(-) diff --git a/src/kata.rs b/src/kata.rs index acc9549..8e4609d 100644 --- a/src/kata.rs +++ b/src/kata.rs @@ -133,11 +133,7 @@ pub fn run( let measured_mb = ((home_size as f64 * 1.2) / (1024.0 * 1024.0)).ceil() as u32; if config.session_mb.is_none() || measured_mb > session_mb { session_mb = measured_mb.max(50); - crate::logging::log(&format!( - "home dir is {} MB, auto-sized session to {} MB", - home_size / (1024 * 1024), - session_mb - )); + crate::logging::log(&format!("home dir is {} MB, auto-sized session to {} MB", home_size / (1024 * 1024), session_mb)); } crate::logging::log("Setting up session image..."); Some(setup_session(hp, session_mb, uid, gid)?) @@ -268,6 +264,7 @@ pub fn run( crate::logging::log("Saving session..."); let cleanup = config.effective_session_cleanup(); teardown_session(hp, sd, &cleanup); + crate::logging::log("session persisted"); } } @@ -276,6 +273,7 @@ pub fn run( if hp.is_dir() { crate::logging::log("Sealing home..."); seal_home(hp, encrypt_patterns, &passphrase)?; + crate::logging::log("home sealed"); } } } @@ -661,11 +659,12 @@ fn decrypt_from_slice(passphrase: &str, encoded: &[u8]) -> Result, Strin /// Recursively walks `dir`, calling `f` with the full path and the path relative to `base` for each file. fn walk_files(base: &Path, dir: &Path, f: &mut dyn FnMut(&Path, &Path) -> Result<(), String>) -> Result<(), String> { for entry in fs::read_dir(dir).map_err(|e| format!("read_dir {}: {e}", dir.display()))? { - let path = entry.map_err(|e| format!("dir entry: {e}"))?.path(); - if path.is_dir() { - walk_files(base, &path, f)?; - } else if path.is_file() { - f(&path, path.strip_prefix(base).unwrap_or(&path))?; + let entry = entry.map_err(|e| format!("dir entry: {e}"))?; + let ft = entry.file_type().map_err(|e| format!("file_type: {e}"))?; + if ft.is_dir() { + walk_files(base, &entry.path(), f)?; + } else if ft.is_file() { + f(&entry.path(), entry.path().strip_prefix(base).unwrap_or(&entry.path()))?; } } Ok(()) @@ -709,7 +708,11 @@ fn seal_home(home: &Path, patterns: &[String], passphrase: &str) -> Result<(), S let compiled: Vec = patterns.iter().map(|p| glob::Pattern::new(p)).collect::, _>>().map_err(|e| format!("invalid glob pattern: {e}"))?; + crate::logging::log("seal: walking home tree..."); + + let scanned = std::cell::Cell::new(0u64); walk_files(home, home, &mut |path, rel| { + scanned.set(scanned.get() + 1); let Some(filename) = path.file_name().and_then(|n| n.to_str()) else { return Ok(()); }; @@ -722,6 +725,9 @@ fn seal_home(home: &Path, patterns: &[String], passphrase: &str) -> Result<(), S return Ok(()); } + let size = fs::metadata(path).map(|m| m.len()).unwrap_or(0); + crate::logging::log(&format!("sealing {} ({} bytes)", rel_str, size)); + let plaintext = fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?; let encrypted = encrypt_to_vec(passphrase, &plaintext)?; @@ -730,7 +736,10 @@ fn seal_home(home: &Path, patterns: &[String], passphrase: &str) -> Result<(), S fs::remove_file(path).map_err(|e| format!("remove {}: {e}", path.display()))?; Ok(()) - }) + })?; + + crate::logging::log(&format!("seal: walked {} files", scanned.get())); + Ok(()) } /// Creates and mounts an ext4 session image, copies the home contents, and recovers any leftover image. @@ -794,14 +803,18 @@ fn setup_session(home_path: &Path, session_mb: u32, uid: &str, gid: &str) -> Res fn teardown_session(home_path: &Path, session_dir: &Path, cleanup_patterns: &[String]) { crate::logging::log("preparing to persist session..."); cleanup_home_junk(session_dir, cleanup_patterns); + crate::logging::log("teardown: cleanup done, copying session back..."); let _ = run_command_quiet("cp", &["-Rup", &format!("{}/.", session_dir.display()), &format!("{}", home_path.display())]); + crate::logging::log("teardown: copy done, unmounting..."); let _ = run_command_allow_failure("sudo", &["umount", &format!("{}", session_dir.display())]); + crate::logging::log("teardown: unmount done, removing image..."); let session_img = home_path.parent().expect("home has parent").join(".bunker").join("session.img"); let _ = run_command_allow_failure("rm", &["-f", &format!("{}", session_img.display())]); let _ = run_command_allow_failure("sudo", &["rmdir", &format!("{}", session_dir.display())]); + crate::logging::log("teardown: image removed"); } /// Checks that containerd >= 2.2.5 is installed (required for Kata networking). Skippable via env var. @@ -888,8 +901,9 @@ fn cleanup_home_junk(path: &Path, patterns: &[String]) { for pattern in patterns { let target = path.join(pattern); if target.exists() { - crate::logging::log(&format!("purging {pattern}")); + crate::logging::log(&format!("purging {pattern}...")); let _ = run_command_allow_failure("rm", &["-rf", &format!("{}", target.display())]); + crate::logging::log(&format!("purged {pattern}")); } } } @@ -900,13 +914,13 @@ fn dir_size(path: &Path) -> u64 { return total; }; for entry in entries.flatten() { - let Ok(meta) = entry.metadata() else { + let Ok(ft) = entry.file_type() else { continue; }; - if meta.is_dir() { + if ft.is_dir() { total = total.saturating_add(dir_size(&entry.path())); - } else if meta.is_file() { - total = total.saturating_add(meta.len()); + } else if ft.is_file() { + total = total.saturating_add(entry.metadata().map(|m| m.len()).unwrap_or(0)); } } total diff --git a/src/main.rs b/src/main.rs index 2f0abfc..f8d82b2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -224,7 +224,8 @@ fn run_packaged_runtime(config: cfg::RuntimeConfig, workspace_override: Option bool { let Ok(entries) = fs::read_dir(dir) else { return false }; for entry in entries.flatten() { - let path = entry.path(); - let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue }; + let Ok(ft) = entry.file_type() else { continue }; + let file_name = entry.file_name(); + let Some(name) = file_name.to_str() else { continue }; if name.starts_with('.') || name.ends_with(".wh") { continue; } - if path.is_dir() { - if check(&path) { + if ft.is_dir() { + if check(&entry.path()) { return true; } continue; } - if path.is_file() { + if ft.is_file() { return true; } } @@ -345,6 +346,7 @@ fn sync_upper(upper_dir: &Path, repo_root: &Path, sessions_dir: &Path, app_name: } save_manifest(&manifest_path, &manifest_new)?; + crate::logging::log(&format!("workspace sync: added {count_add}, deleted {count_del}")); Ok((count_add, count_del)) } @@ -360,19 +362,23 @@ fn sync_upper_dir( } let mut has_opq = false; + let mut scanned: u64 = 0; for entry in fs::read_dir(current).map_err(|e| format!("failed to read {}: {e}", current.display()))? { let entry = entry.map_err(|e| format!("failed to read entry: {e}"))?; let path = entry.path(); - let metadata = entry.metadata().map_err(|e| format!("failed to stat {}: {e}", path.display()))?; + let ft = entry.file_type().map_err(|e| format!("failed to stat {}: {e}", path.display()))?; + + scanned += 1; + if scanned.is_multiple_of(500) { + crate::logging::log(&format!("workspace sync: scanned {scanned} entries in {}...", current.display())); + } let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue; }; - let file_type = metadata.file_type(); - - if file_type.is_char_device() { + if ft.is_char_device() { if name == ".wh..wh..opq" { has_opq = true; continue; @@ -393,7 +399,7 @@ fn sync_upper_dir( continue; } - if file_type.is_symlink() { + if ft.is_symlink() { continue; } @@ -401,12 +407,12 @@ fn sync_upper_dir( let rel_str = rel.to_string_lossy().to_string(); let dest = repo_root.join(rel); - if metadata.is_dir() { + if ft.is_dir() { sync_upper_dir(base, &path, repo_root, count_add, count_del, _manifest_old, manifest_new)?; continue; } - if metadata.is_file() { + if ft.is_file() { let needs_copy = fs::read(&path).ok().and_then(|src| fs::read(&dest).ok().map(|dest_data| src != dest_data)).unwrap_or(true); if needs_copy { if let Some(parent) = dest.parent() { @@ -466,6 +472,7 @@ fn save_manifest(path: &Path, manifest: &BTreeSet) -> Result<(), String> /// removes session state, and unmounts the overlay and loop device. impl Drop for CowWorkspace { fn drop(&mut self) { + crate::logging::log("workspace: dropping..."); let sessions_dir = self.overlay_dir.join("sessions"); for entry in fs::read_dir(&sessions_dir).into_iter().flatten() { @@ -475,25 +482,30 @@ impl Drop for CowWorkspace { let stem = path.file_stem().unwrap_or_default().to_string_lossy(); let synced_marker = sessions_dir.join(format!("{stem}.synced")); if !synced_marker.exists() && self.has_unsynced_changes() { - eprintln!("bunkerbox: syncing changes back to repo..."); + crate::logging::log("workspace: syncing changes back to repo..."); let upper_dir = self.loop_mount.join("upper"); let repo_root = self.overlay_dir.parent().expect("overlay_dir has no parent"); let _ = sync_upper(&upper_dir, repo_root, &sessions_dir, &stem); - eprintln!("bunkerbox: sync complete."); + crate::logging::log("workspace: sync complete."); } let _ = fs::remove_file(&path); let _ = fs::remove_file(&synced_marker); } } + crate::logging::log("workspace: removing sessions dir..."); let _ = fs::remove_dir_all(&sessions_dir); + crate::logging::log("workspace: unmounting overlay..."); let _ = Self::run_command_allow_failure("umount", &[&self.mount_point.to_string_lossy()]); + crate::logging::log("workspace: unmounting loop mount..."); let _ = Self::run_command_allow_failure("umount", &[&self.loop_mount.to_string_lossy()]); if let Some(dev) = Self::find_loop_device(&self.loopback) { if !dev.is_empty() { + crate::logging::log("workspace: detaching loop device..."); let _ = Self::run_command_allow_failure("losetup", &["-d", &dev]); } } + crate::logging::log("workspace: dropped"); } } diff --git a/src/tui.rs b/src/tui.rs index 3914977..61e677e 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -6,7 +6,7 @@ use std::sync::{Arc, Mutex}; use std::time::Instant; use crossterm::cursor; -use crossterm::event::{self, Event}; +use crossterm::event::{self}; use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; use crossterm::ExecutableCommand; use ratatui::backend::CrosstermBackend; @@ -51,6 +51,7 @@ pub struct OverlayState { pub popup: PopupWidget, pub popup_title: Option, pub pending: Vec, + pub has_error: bool, } impl Default for OverlayState { @@ -61,7 +62,7 @@ impl Default for OverlayState { impl OverlayState { pub fn new() -> Self { - Self { status_text: String::new(), popup: PopupWidget::new(), popup_title: None, pending: Vec::new() } + Self { status_text: String::new(), popup: PopupWidget::new(), popup_title: None, pending: Vec::new(), has_error: false } } } @@ -112,6 +113,9 @@ pub fn dispatch_ui_command(state: &mut OverlayState, widget: &str, command: &str } (WIDGET_POPUP, "info") => { let title = if options.is_empty() { None } else { Some(options.to_string()) }; + if title.as_deref() == Some("Error") { + state.has_error = true; + } state.popup.show_info(title, value, Some(palette::FG), Some(palette::ACCENT)); } (WIDGET_POPUP, CMD_HIDE) => { @@ -315,23 +319,17 @@ fn push_dec_special_graphic(out: &mut Vec, byte: u8) { } fn hold_error_popup(overlay: &Arc>, term: &mut Terminal>, screen: &vt100::Screen) { - let deadline = Instant::now() + std::time::Duration::from_secs(30); - loop { - { - let state = overlay.lock().unwrap(); - term.draw(|f| render_frame(f, screen, &state)).ok(); - } - - if Instant::now() >= deadline { - break; - } + let is_error = overlay.lock().unwrap().has_error; + if !is_error { + return; + } - if event::poll(std::time::Duration::from_millis(200)).unwrap_or(false) { - if let Ok(Event::Key(_)) = event::read() { - break; - } - } + { + let mut state = overlay.lock().unwrap(); + state.popup.show_info(Some("Error".to_string()), "Press any key to exit", Some(palette::FG), Some(palette::ACCENT)); } + term.draw(|f| render_frame(f, screen, &overlay.lock().unwrap())).ok(); + let _ = event::read(); } /// Renders PTY output through ratatui until the child process exits. diff --git a/src/tui/palette.rs b/src/tui/palette.rs index 275dbaa..202b3ad 100644 --- a/src/tui/palette.rs +++ b/src/tui/palette.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + use ratatui::style::Color; // --------------------------------------------------------------------------- From c5104e331b22778689e783fff23b679ee430120c Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 29 Jul 2026 21:19:34 +0200 Subject: [PATCH 18/42] Add password popup --- src/tui/popup.rs | 37 ++++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/src/tui/popup.rs b/src/tui/popup.rs index 23f79bc..5617c2d 100644 --- a/src/tui/popup.rs +++ b/src/tui/popup.rs @@ -15,7 +15,7 @@ const SPINNER_FPS_MS: u64 = 83; pub enum PopupContent { Spinner { message: String, model: spinner::Model, last_tick: Instant }, Progress { title: String, percent: f64, label: Option }, - Password { title: String, prompt: String }, + Password { title: String, prompt: String, value: String }, Info { title: Option, message: String, fg: ratatui::style::Color }, } @@ -59,7 +59,7 @@ impl PopupWidget { pub fn show_password(&mut self, title: impl Into, prompt: impl Into) { self.border_color = palette::ACCENT; - self.content = PopupContent::Password { title: title.into(), prompt: prompt.into() }; + self.content = PopupContent::Password { title: title.into(), prompt: prompt.into(), value: String::new() }; self.visible = true; } @@ -85,6 +85,29 @@ impl PopupWidget { self.visible = false; } + pub fn push_char(&mut self, c: char) { + if let PopupContent::Password { ref mut value, .. } = &mut self.content { + value.push(c); + } + } + + pub fn pop_char(&mut self) { + if let PopupContent::Password { ref mut value, .. } = &mut self.content { + value.pop(); + } + } + + pub fn take_password(&mut self) -> Option { + if matches!(self.content, PopupContent::Password { .. }) { + let old = + std::mem::replace(&mut self.content, PopupContent::Password { title: String::new(), prompt: String::new(), value: String::new() }); + if let PopupContent::Password { value, .. } = old { + return Some(value); + } + } + None + } + /// Compute the natural content dimensions (width, height) for the popup interior. fn content_size(&self) -> (u16, u16) { match &self.content { @@ -97,7 +120,7 @@ impl PopupWidget { let w = (title.len() as u16).max(label_w).max(40); (w + 8, 7) } - PopupContent::Password { title: _, prompt } => { + PopupContent::Password { title: _, prompt, .. } => { let w = (prompt.len() as u16 + 20).max(40); (w, 7) } @@ -149,8 +172,8 @@ impl PopupWidget { PopupContent::Progress { title: _, percent, label } => { self.render_progress(inner, buf, *percent, label); } - PopupContent::Password { title: _, prompt } => { - self.render_password(inner, buf, prompt); + PopupContent::Password { title: _, prompt, value } => { + self.render_password(inner, buf, prompt, value); } PopupContent::Info { message, fg, .. } => { self.render_info(inner, buf, message, *fg); @@ -245,7 +268,7 @@ impl PopupWidget { } } - fn render_password(&self, inner: Rect, buf: &mut Buffer, prompt: &str) { + fn render_password(&self, inner: Rect, buf: &mut Buffer, prompt: &str, value: &str) { let [_, prompt_area, input_area, _]: [Rect; 4] = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(0), Constraint::Length(1), Constraint::Length(2), Constraint::Min(0)]) @@ -261,7 +284,7 @@ impl PopupWidget { .alignment(Alignment::Center) .render(Rect { x: px, y: prompt_area.y, width: pw, height: 1 }, buf); - let mask = "\u{2022}".repeat(12); + let mask = "\u{2022}".repeat(if value.is_empty() { 12 } else { value.len() }); let mw = mask.len() as u16; let mx = input_area.x + (input_area.width.saturating_sub(mw)) / 2; Paragraph::new(Line::from(vec![Span::styled(mask, Style::default().fg(palette::MUTED).bg(palette::BG_3))])) From aff2a3c205153fe4ef94cc5c757b256c30181672 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 29 Jul 2026 21:20:00 +0200 Subject: [PATCH 19/42] Add password grabber from TUI --- Cargo.lock | 22 ---------------------- Cargo.toml | 1 - src/kata.rs | 4 ++-- src/logging.rs | 36 ++++++++++++++++++++++++++++++++++++ src/tui.rs | 24 ++++++++++++++++++++++-- 5 files changed, 60 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 53b33ec..847fe1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -204,7 +204,6 @@ dependencies = [ "rand", "ratatui", "ratatui-glamour", - "rpassword", "serde", "serde_json", "serde_yaml", @@ -1517,27 +1516,6 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" -[[package]] -name = "rpassword" -version = "7.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" -dependencies = [ - "libc", - "rtoolbox", - "windows-sys 0.61.2", -] - -[[package]] -name = "rtoolbox" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - [[package]] name = "rustc_version" version = "0.4.1" diff --git a/Cargo.toml b/Cargo.toml index c6fb512..6816112 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,6 @@ glob = "0.3" libc = "0.2" pbkdf2 = "0.12" rand = "0.8" -rpassword = "7" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" diff --git a/src/kata.rs b/src/kata.rs index 8e4609d..357773d 100644 --- a/src/kata.rs +++ b/src/kata.rs @@ -609,9 +609,9 @@ fn confirm(prompt: &str) -> bool { buf[0] == b'y' || buf[0] == b'Y' } -/// Reads a passphrase from the terminal without echoing. +/// Reads a passphrase from the terminal via the TUI password popup. fn read_passphrase() -> Result { - rpassword::prompt_password("Bunkerbox passphrase: ").map_err(|err| format!("failed to read passphrase: {err}")) + crate::logging::prompt_password("Bunkerbox passphrase", "Enter passphrase") } /// Derives a 256-bit AES key from a passphrase and salt via PBKDF2-HMAC-SHA256 (100k iterations). diff --git a/src/logging.rs b/src/logging.rs index 8d9230d..0d22969 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -17,6 +17,42 @@ pub fn set_status_fd(fd: RawFd) { STATUS_FD.with(|f| *f.borrow_mut() = Some(fd)); } +/// Sends a password prompt to the TUI, blocks reading the response from the status fd. +pub fn prompt_password(title: &str, prompt: &str) -> Result { + let fd = STATUS_FD.with(|f| f.borrow().ok_or("status fd not set".to_string()))?; + + let payload = crate::vscomm::encode_ui_payload("password", "show", title, prompt); + let mut buf = b"@".to_vec(); + buf.extend_from_slice(&payload); + buf.push(b'\n'); + unsafe { + libc::write(fd, buf.as_ptr() as *const libc::c_void, buf.len()); + } + + let mut response = Vec::new(); + let mut byte = [0u8; 1]; + loop { + let n = unsafe { libc::read(fd, byte.as_mut_ptr() as *mut libc::c_void, 1) }; + if n <= 0 { + return Err("failed to read password response".to_string()); + } + if byte[0] == b'\n' { + break; + } + response.push(byte[0]); + } + + let payload = crate::vscomm::encode_ui_payload("password", "hide", "", ""); + let mut buf = b"@".to_vec(); + buf.extend_from_slice(&payload); + buf.push(b'\n'); + unsafe { + libc::write(fd, buf.as_ptr() as *const libc::c_void, buf.len()); + } + + String::from_utf8(response).map_err(|e| format!("invalid password encoding: {e}")) +} + pub fn log(msg: &str) { let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs(); let line = format!("[{ts}] {msg}\n"); diff --git a/src/tui.rs b/src/tui.rs index 61e677e..1453ca9 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -422,8 +422,28 @@ where if fds[1].revents & libc::POLLIN != 0 { let n = unsafe { libc::read(stdin_fd, stdin_buf.as_mut_ptr() as *mut libc::c_void, 1usize) }; if n > 0 { - unsafe { - libc::write(master_fd, stdin_buf.as_ptr() as *const libc::c_void, 1usize); + let byte = stdin_buf[0]; + let is_password = overlay.lock().is_ok_and(|s| matches!(s.popup.content, popup::PopupContent::Password { .. })); + if is_password { + let mut state = overlay.lock().unwrap(); + if byte == 0x0d { + if let Some(password) = state.popup.take_password() { + let mut response = password.into_bytes(); + response.push(b'\n'); + state.popup.hide(); + unsafe { + libc::write(status_fd, response.as_ptr() as *const libc::c_void, response.len()); + } + } + } else if byte == 0x7f { + state.popup.pop_char(); + } else if byte >= 0x20 && byte != 0x7f { + state.popup.push_char(byte as char); + } + } else { + unsafe { + libc::write(master_fd, stdin_buf.as_ptr() as *const libc::c_void, 1usize); + } } } } From 24a446ad46ccb99543e0059cdb160318bd8c8bc6 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Thu, 30 Jul 2026 01:22:05 +0200 Subject: [PATCH 20/42] Update makefile for musl-based dist --- Makefile | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 2499b3c..8936b6a 100644 --- a/Makefile +++ b/Makefile @@ -42,14 +42,26 @@ ensure-toolchain: rustup target add $(VSCOMM_TARGET) dev: ensure-toolchain - cargo build + cargo build --bin bunkerbox --bin bunkerbox-image cargo build --bin bunkerbox-vscomm --target $(VSCOMM_TARGET) cargo build --bin bunkerbox-status --target $(VSCOMM_TARGET) + rm -rf target/dist + mkdir -p target/dist + cp target/debug/bunkerbox target/dist/ + cp target/debug/bunkerbox-image target/dist/ + cp target/$(VSCOMM_TARGET)/debug/bunkerbox-vscomm target/dist/ + cp target/$(VSCOMM_TARGET)/debug/bunkerbox-status target/dist/ release: ensure-toolchain - cargo build --release + cargo build --bin bunkerbox --bin bunkerbox-image --release cargo build --bin bunkerbox-vscomm --target $(VSCOMM_TARGET) --release cargo build --bin bunkerbox-status --target $(VSCOMM_TARGET) --release + rm -rf target/dist + mkdir -p target/dist + cp target/release/bunkerbox target/dist/ + cp target/release/bunkerbox-image target/dist/ + cp target/$(VSCOMM_TARGET)/release/bunkerbox-vscomm target/dist/ + cp target/$(VSCOMM_TARGET)/release/bunkerbox-status target/dist/ check: cargo fmt --all From 0df1cc51e6f65068b9fbe85cd6edb507a58ad439 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Thu, 30 Jul 2026 01:22:54 +0200 Subject: [PATCH 21/42] Implement vsock communication with the outer TUI wrapper --- demo/netdebug/run-demo.sh | 11 ++++++- images/crush.conf | 17 +++++++---- images/kilocode.conf | 16 +++++++---- images/opencode.conf | 17 +++++++---- src/bin/bunkerbox-image.rs | 57 ++++++++++++++----------------------- src/bin/bunkerbox-status.rs | 28 ++++++++++++++++-- src/bin/bunkerbox-vscomm.rs | 21 +++++++++++++- src/cfg.rs | 2 ++ src/kata.rs | 44 ++++++++++++++++++++-------- src/lib.rs | 1 + src/main.rs | 12 ++++++-- src/wrap.rs | 34 ++++++++++++++++++++++ 12 files changed, 191 insertions(+), 69 deletions(-) create mode 100644 src/wrap.rs diff --git a/demo/netdebug/run-demo.sh b/demo/netdebug/run-demo.sh index 88d5298..841c64d 100755 --- a/demo/netdebug/run-demo.sh +++ b/demo/netdebug/run-demo.sh @@ -113,6 +113,8 @@ files: echo echo "Interactive shell." + echo + echo "To test VSOCK: bunkerbox-status popup info \"\" \"hello from vm\"" exec /bin/sh containerfile: | @@ -128,9 +130,17 @@ containerfile: | netcat-openbsd \ openssl + RUN mkdir -p /workspace /usr/local/bunkerbox/bin \ + && chmod 0777 /workspace + COPY debug.sh /usr/local/bin/debug.sh RUN chmod 0755 /usr/local/bin/debug.sh + COPY bunkerbox-vscomm /usr/local/bunkerbox/bin/bunkerbox-vscomm + COPY bunkerbox-status /usr/local/bunkerbox/bin/bunkerbox-status + + ENV PATH="/usr/local/bunkerbox/bin:$PATH" + WORKDIR /workspace ENTRYPOINT ["/usr/local/bin/debug.sh"] EOF_IMAGE @@ -141,7 +151,6 @@ cat > "$RUNTIME_CONF" <, #[serde(default)] overwrite: bool, @@ -127,8 +128,8 @@ fn build_image(config: &ImageConfig) -> Result<(), String> { return Err("image config name is required".to_string()); } - if config.command.is_empty() || config.command.iter().any(|part| part.trim().is_empty()) { - return Err("image config command is required".to_string()); + if !config.command.is_empty() && config.command.iter().any(|part| part.trim().is_empty()) { + return Err("image config command must not contain empty strings".to_string()); } if config.output.exists() { @@ -224,6 +225,16 @@ run_app() {{ return "$status" }} +VSCOMM_BIN="/usr/local/bunkerbox/bin" +if [ -x "$VSCOMM_BIN/bunkerbox-vscomm" ]; then + "$VSCOMM_BIN/bunkerbox-vscomm" install + export PATH="$VSCOMM_BIN:$PATH" +fi + +if ! command -v bunkerbox-status >/dev/null 2>&1; then + bunkerbox-status() {{ :; }} +fi + if [ -n "${{BUNKERBOX_PERSIST_HOME:-}}" ]; then hook_before_home_load @@ -234,12 +245,6 @@ if [ -n "${{BUNKERBOX_PERSIST_HOME:-}}" ]; then export XDG_CACHE_HOME="$HOME/.cache" fi -VSCOMM_BIN="/usr/local/bunkerbox/bin" -if [ -x "$VSCOMM_BIN/bunkerbox-vscomm" ]; then - "$VSCOMM_BIN/bunkerbox-vscomm" install - export PATH="$VSCOMM_BIN:$PATH" -fi - set +e run_app status=$? @@ -307,38 +312,20 @@ fn write_build_context(config: &ImageConfig, build_dir: &Path) -> Result<(), Str Ok(()) } -fn find_vscomm_binary() -> Result { +fn dist_dir() -> Result { let exe = env::current_exe().map_err(|e| format!("failed to locate self: {e}"))?; - let parent = exe.parent().ok_or_else(|| "failed to determine parent directory".to_string())?; - let target_dir = parent.parent().ok_or_else(|| "failed to determine target directory".to_string())?; - - for profile in &["release", "debug"] { - let musl_candidate = target_dir.join("x86_64-unknown-linux-musl").join(profile).join("bunkerbox-vscomm"); - if musl_candidate.is_file() { - return Ok(musl_candidate); - } - } + let target = exe.parent().and_then(|p| p.parent()).ok_or_else(|| "cannot determine target directory".to_string())?; + Ok(target.join("dist")) +} - Err("musl-static bunkerbox-vscomm not found in target/x86_64-unknown-linux-musl/{release,debug}/\n\ - build it first: make musl-vscomm" - .to_string()) +fn find_vscomm_binary() -> Result { + let path = dist_dir()?.join("bunkerbox-vscomm"); + if path.is_file() { Ok(path) } else { Err("bunkerbox-vscomm not found in target/dist/. Run: make dev".into()) } } fn find_status_binary() -> Result { - let exe = env::current_exe().map_err(|e| format!("failed to locate self: {e}"))?; - let parent = exe.parent().ok_or_else(|| "failed to determine parent directory".to_string())?; - let target_dir = parent.parent().ok_or_else(|| "failed to determine target directory".to_string())?; - - for profile in &["release", "debug"] { - let musl_candidate = target_dir.join("x86_64-unknown-linux-musl").join(profile).join("bunkerbox-status"); - if musl_candidate.is_file() { - return Ok(musl_candidate); - } - } - - Err("musl-static bunkerbox-status not found in target/x86_64-unknown-linux-musl/{release,debug}/\n\ - build it first: make musl-vscomm" - .to_string()) + let path = dist_dir()?.join("bunkerbox-status"); + if path.is_file() { Ok(path) } else { Err("bunkerbox-status not found in target/dist/. Run: make dev".into()) } } fn podman_build(config: &ImageConfig, build_dir: &Path) -> Result<(), String> { diff --git a/src/bin/bunkerbox-status.rs b/src/bin/bunkerbox-status.rs index 35751b9..ba008fa 100644 --- a/src/bin/bunkerbox-status.rs +++ b/src/bin/bunkerbox-status.rs @@ -22,8 +22,17 @@ fn run() -> Result<(), String> { let widget = args.get(1).ok_or_else(|| "usage: bunkerbox-status [options] [value]".to_string())?; let command = args.get(2).ok_or_else(|| "usage: bunkerbox-status [options] [value]".to_string())?; - let options = args.get(3).map(|s| s.as_str()).unwrap_or(""); - let value = args.get(4).map(|s| s.as_str()).unwrap_or(""); + let (options, value) = match args.len() { + n if n >= 5 => (args[3].as_str(), args[4].as_str()), + 4 => { + if args[3].starts_with("SEC_") || args[3] == "ON_PTY" { + (args[3].as_str(), "") + } else { + ("", args[3].as_str()) + } + } + _ => ("", ""), + }; if !widget.is_empty() && !command.is_empty() { let payload = vscomm::encode_ui_payload(widget, command, options, value); @@ -38,6 +47,21 @@ fn run() -> Result<(), String> { } fn vsock_connect(cid: u32, port: u32) -> io::Result { + let delays = [100u64, 500, 1000]; + for (i, &ms) in delays.iter().enumerate() { + match try_connect(cid, port) { + Ok(s) => return Ok(s), + Err(e) if i < delays.len() - 1 => { + eprintln!("bunkerbox-status: vsock connect attempt {} failed: {e}", i + 1); + std::thread::sleep(std::time::Duration::from_millis(ms)); + } + Err(e) => return Err(e), + } + } + unreachable!() +} + +fn try_connect(cid: u32, port: u32) -> io::Result { unsafe { let fd = libc::socket(libc::AF_VSOCK, libc::SOCK_STREAM, 0); if fd < 0 { diff --git a/src/bin/bunkerbox-vscomm.rs b/src/bin/bunkerbox-vscomm.rs index 49035e8..708ad0e 100644 --- a/src/bin/bunkerbox-vscomm.rs +++ b/src/bin/bunkerbox-vscomm.rs @@ -8,7 +8,7 @@ use std::mem; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; -use vscomm::{ExecRequest, Frame, FrameType, VSCOMM_BIN_DIR, VSOCK_PORT}; +use vscomm::{encode_ui_payload, ExecRequest, Frame, FrameType, STATUS_PORT, VSCOMM_BIN_DIR, VSOCK_PORT}; const HOST_CID: u32 = 2; @@ -34,6 +34,10 @@ fn run() -> Result<(), String> { .ok_or_else(|| "bunkerbox-vscomm must be invoked via symlink (not directly)".to_string())? .to_string(); + let label = if args.len() > 1 { format!("{} {}", invoked_as, args[1..].join(" ")) } else { invoked_as.clone() }; + + send_status(&format!("Running: {label}")); + let cwd = env::current_dir().map_err(|e| format!("cwd: {e}"))?; let env_vars: Vec<(String, String)> = env::vars().collect(); @@ -58,6 +62,7 @@ fn run() -> Result<(), String> { io::stderr().flush().map_err(|e| format!("flush stderr: {e}"))?; } FrameType::Exit => { + send_status(&format!("Done: {label}")); if response.payload.len() >= 4 { let code = i32::from_le_bytes([response.payload[0], response.payload[1], response.payload[2], response.payload[3]]); std::process::exit(code); @@ -179,6 +184,20 @@ fn command_exists_in_path_except(cmd: &str, except: &Path) -> bool { false } +fn send_status(msg: &str) { + let result = (|| -> Result<(), String> { + let payload = encode_ui_payload("status", "set", "", msg); + let frame = Frame::new(FrameType::UiCommand, payload); + let mut stream = vsock_connect(HOST_CID, STATUS_PORT).map_err(|e| format!("status connect: {e}"))?; + frame.write(&mut stream).map_err(|e| format!("status write: {e}"))?; + stream.flush().map_err(|e| format!("status flush: {e}"))?; + Ok(()) + })(); + if let Err(e) = result { + eprintln!("bunkerbox-vscomm: status: {e}"); + } +} + fn vsock_connect(cid: u32, port: u32) -> io::Result { unsafe { let fd = libc::socket(libc::AF_VSOCK, libc::SOCK_STREAM, 0); diff --git a/src/cfg.rs b/src/cfg.rs index f8da475..89b5e9c 100644 --- a/src/cfg.rs +++ b/src/cfg.rs @@ -97,6 +97,8 @@ pub struct RuntimeConfig { pub session_mb: Option, #[serde(default)] pub session_cleanup: Option>, + #[serde(default)] + pub command: Option>, } impl RuntimeConfig { diff --git a/src/kata.rs b/src/kata.rs index 357773d..e0d7faf 100644 --- a/src/kata.rs +++ b/src/kata.rs @@ -11,6 +11,7 @@ use std::collections::BTreeSet; use std::fs; use std::io::{BufRead, BufReader}; use std::net::{IpAddr, ToSocketAddrs}; +use std::os::unix::fs::PermissionsExt; use std::os::unix::io::RawFd; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; @@ -40,17 +41,8 @@ fn cleanup_partial_session(session_dir: Option<&PathBuf>, home_path: Option<&Pat pub fn run( config: &RuntimeConfig, workspace: WorkspaceHandle, container_name: &str, _share_dir: &Path, app_name: &str, vsock_enabled: bool, - status_fd: RawFd, + _status_fd: RawFd, ) -> Result<(), String> { - fn write_ui_cmd(fd: RawFd, widget: &str, command: &str, options: &str, value: &str) { - let payload = crate::vscomm::encode_ui_payload(widget, command, options, value); - let mut buf = b"@".to_vec(); - buf.extend_from_slice(&payload); - buf.push(b'\n'); - unsafe { - libc::write(fd, buf.as_ptr() as *const libc::c_void, buf.len()); - } - } if !config.oci.is_file() { return Err(format!("OCI archive not found: {}", config.oci.display())); @@ -171,6 +163,8 @@ pub fn run( let resolv_conf_mount = resolv_conf.as_ref().map(|path| format!("type=bind,src={},dst=/etc/resolv.conf,options=rbind:ro", path.display())); let workspace_mount = format!("type=bind,src={},dst=/workspace,options=rbind:rw", workspace.path().display()); let mut container_env = Vec::new(); + let mut tools_mount: Option = None; + let init_cmd = String::from("/bunkerbox-tools/init.sh"); let home_mount = if let Some(ref hp) = home_path { let src = if let Some(ref sd) = session_dir { sd.display().to_string() @@ -192,6 +186,24 @@ pub fn run( container_env.push(format!("BUNKERBOX_VSOCK_PORT={VSOCK_PORT}")); } + if let Some(ref cmds) = config.command { + if !cmds.is_empty() { + let tools_dir = match std::env::var("BUNKERBOX_TOOLS_DIR") { + Ok(d) if !d.is_empty() => PathBuf::from(d), + _ => { + let dir = std::env::temp_dir().join(format!("bunkerbox-tools-{}", std::process::id())); + fs::create_dir_all(&dir).map_err(|e| format!("create tools dir: {e}"))?; + let init = crate::wrap::generate_init_script(cmds); + fs::write(dir.join("init.sh"), init.as_bytes()).map_err(|e| format!("write init.sh: {e}"))?; + fs::set_permissions(dir.join("init.sh"), fs::Permissions::from_mode(0o755)).map_err(|e| format!("chmod init.sh: {e}"))?; + dir + } + }; + + tools_mount = Some(format!("type=bind,src={},dst=/bunkerbox-tools,options=rbind:ro", tools_dir.display())); + } + } + let mut args = Vec::new(); if config.network == Some(NetworkMode::Bridge) { @@ -233,6 +245,11 @@ pub fn run( args.push(mount.as_str()); } + if let Some(mount) = &tools_mount { + args.push("--mount"); + args.push(mount.as_str()); + } + match config.network { Some(NetworkMode::Bridge) => args.push("--cni"), Some(NetworkMode::Host) => args.push("--net-host"), @@ -242,12 +259,15 @@ pub fn run( args.push(&config.image); args.push(container_name); + if tools_mount.is_some() { + crate::logging::log("Wrapping commands..."); + args.push(&init_cmd); + } + crate::logging::log("Starting container..."); crate::logging::log("Connected"); - write_ui_cmd(status_fd, "popup", "hide", "ON_PTY,SEC_6", ""); - let result = run_command("sudo", &args); crate::logging::log("Container stopped"); diff --git a/src/lib.rs b/src/lib.rs index 5e04a3f..f7c2318 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,3 +11,4 @@ pub mod sandbox; pub mod tui; pub mod vscomm; pub mod workspace; +pub mod wrap; diff --git a/src/main.rs b/src/main.rs index f8d82b2..9b92ac1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -272,12 +272,18 @@ fn run_packaged_runtime(config: cfg::RuntimeConfig, workspace_override: Option>) { + use std::time::Duration; use tokio::io::AsyncReadExt; use tokio_vsock::VsockListener; - let listener = match VsockListener::bind(tokio_vsock::VsockAddr::new(libc::VMADDR_CID_ANY, vscomm::STATUS_PORT)) { - Ok(l) => l, - Err(_) => return, + let listener = loop { + match VsockListener::bind(tokio_vsock::VsockAddr::new(libc::VMADDR_CID_ANY, vscomm::STATUS_PORT)) { + Ok(l) => break l, + Err(e) => { + eprintln!("bunkerbox: vsock status listener bind failed ({e}), retrying..."); + std::thread::sleep(Duration::from_millis(500)); + } + } }; loop { diff --git a/src/wrap.rs b/src/wrap.rs new file mode 100644 index 0000000..d513d71 --- /dev/null +++ b/src/wrap.rs @@ -0,0 +1,34 @@ +/// Generates a POSIX shell init script that wraps each command +/// with `bunkerbox-status` progress reporting and runs them sequentially. +/// Falls through to an interactive shell after the command list. +pub fn generate_init_script(commands: &[String]) -> String { + let mut script = String::from( + "#!/bin/sh\n\ + export PATH=/usr/local/bunkerbox/bin:/bunkerbox-tools:$PATH\n\ + \n", + ); + + script.push_str("bunkerbox-vscomm install 2>/dev/null\n"); + script.push_str("cd /workspace\n\n"); + + let count = commands.len(); + for (i, cmd) in commands.iter().enumerate() { + let label = truncate_label(cmd, 60); + script.push_str(&format!("bunkerbox-status status set \"Running: {label}\"\n")); + if i == count.saturating_sub(1) { + script.push_str("bunkerbox-status popup hide \"SEC_2\"\n"); + } + script.push_str(&format!("{cmd}\n")); + script.push_str(&format!("bunkerbox-status status set \"Done: {label}\"\n\n")); + } + + script +} + +fn truncate_label(s: &str, max: usize) -> &str { + if s.len() <= max { + s + } else { + &s[..max] + } +} From 8e1d61d8da867bbf23299e24e9468af2b869545b Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Thu, 30 Jul 2026 01:34:28 +0200 Subject: [PATCH 22/42] Update docs --- docs/config/hooks.md | 87 ++++++++++++++++++++++-------------- docs/config/image.md | 80 ++++++++++++++++++++------------- docs/guides/custom-images.md | 76 ++++++++++++++++++++++--------- 3 files changed, 158 insertions(+), 85 deletions(-) diff --git a/docs/config/hooks.md b/docs/config/hooks.md index 5d429dc..787d6bc 100644 --- a/docs/config/hooks.md +++ b/docs/config/hooks.md @@ -1,63 +1,84 @@ # Image hooks -Image hooks are small shell scripts stored in an image config. They run inside the container. +Hooks let you run shell commands at specific points during your app's lifecycle inside the container. They're defined in your image config. -Hooks are useful when a tool needs a little setup before it starts or cleanup after it exits. For example, a hook can create config directories, mark the workspace as safe for Git, print diagnostics when the app fails, or remove cache before state is saved. +## When hooks run -Build an image with hooks the same way you build any image config: +Hooks fire in this order: -```sh -make image IMAGE=images/opencode.conf +``` +container starts +├─ before-home-load ← before loading your saved home directory +├─ before-app ← right before your app starts +├─ your app runs +├─ after-app ← after your app exits (always) +├─ app-error ← only if your app failed (exit code ≠ 0) +├─ after-home-save ← before saving your home directory +container stops ``` -## Hook order +## Showing status messages -The generated entrypoint runs hooks in this order: +During startup, Bunkerbox shows a status overlay while the container boots. You can update what it says from inside a hook using the `bunkerbox-status` command: -```text -container starts -before-home-load -before-app -run app -after-app -app-error, only if app failed -after-home-save -container exits +```yaml +hooks: + before-home-load: | + bunkerbox-status status set 'Loading saved state...' + + before-app: | + bunkerbox-status status set 'Starting my-app...' +``` + +The overlay hides automatically when your app produces its first output. If you want it to fade with a delay instead, use the `popup hide` command with a `SEC_` argument: + +```yaml +hooks: + before-app: | + bunkerbox-status status set 'Launching my-app...' + bunkerbox-status popup hide "SEC_2" ``` -All session management (creating the loop-mounted ext4 image, populating it from the persist home, recovering from crashes, syncing back on exit) happens on the host before the container starts and after it exits. The entrypoint inside the container is trivial — it just sets `HOME` and runs the app. +This shows "Launching my-app..." and then fades the overlay after 2 seconds, while your app is already running. -`before-home-load` runs before the app starts but after the session home is bind-mounted. `before-app` runs right before the app command starts. `after-app` runs after the app exits, whether it succeeded or failed. `app-error` only runs when the app exits with a non-zero status. `after-home-save` runs before the container exits (before the host-side sync back to the persist home). +## Practical examples -## Example +Set up Git for the workspace: ```yaml hooks: before-app: | git config --global --add safe.directory /workspace +``` + +Clean up cache after the app exits: +```yaml +hooks: after-app: | rm -rf "$HOME/.cache" - - app-error: | - echo "app failed with status $BUNKERBOX_APP_STATUS" ``` -The app exit code is available as: +Show an error message when the app crashes: -```text -BUNKERBOX_APP_STATUS +```yaml +hooks: + app-error: | + bunkerbox-status popup info 'Error' 'The app exited unexpectedly' ``` -When persistent home is enabled, hooks can reference these paths: +## Available paths -```text -BUNKERBOX_PERSIST_HOME=/bunkerbox-persist-home +When persistent home is enabled, your hooks can use these paths: + +``` HOME=/bunkerbox-persist-home -XDG_CONFIG_HOME=/bunkerbox-persist-home/.config -XDG_DATA_HOME=/bunkerbox-persist-home/.local/share -XDG_STATE_HOME=/bunkerbox-persist-home/.local/state -XDG_CACHE_HOME=/bunkerbox-persist-home/.cache +XDG_CONFIG_HOME=$HOME/.config +XDG_DATA_HOME=$HOME/.local/share +XDG_STATE_HOME=$HOME/.local/state +XDG_CACHE_HOME=$HOME/.cache ``` -Empty hooks do nothing. Hooks use `/bin/sh`. +The app's exit code is available in the `BUNKERBOX_APP_STATUS` variable inside `after-app` and `app-error` hooks. + +Empty hooks are fine — they simply do nothing. Hooks use `/bin/sh`. diff --git a/docs/config/image.md b/docs/config/image.md index 567afb9..5348ecf 100644 --- a/docs/config/image.md +++ b/docs/config/image.md @@ -1,18 +1,16 @@ # Image config -An image config tells Bunkerbox how to build an OCI archive for a tool. +An image config is a YAML file that tells Bunkerbox how to package your tool into a container image. You put these under the `images/` directory. -The config contains the image name, output archive name, app command, build arguments, hooks, optional extra files, and the container build recipe. You do not run the image builder directly. Use the Makefile: +To build one: ```sh -make image IMAGE=images/opencode.conf +make image IMAGE=images/my-tool.conf ``` -The `IMAGE` parameter is the path to the image config. You can point it at any config file under `images/`. +## A minimal example -## Example - -This is the important top part of the OpenCode image config: +Here is the top of the OpenCode image config: ```yaml name: opencode @@ -26,38 +24,63 @@ build_args: OPENCODE_VERSION: "1.17.18" ``` -The `output` field decides the OCI archive name. After building this config, the archive is written as: +The `output` field names the archive. After building, you get `bunkerbox-opencode-1.17.18.oci`. -```text -bunkerbox-opencode-1.17.18.oci -``` +## Required fields + +| Field | Purpose | +|---|---| +| `name` | Short name for the config | +| `image` | Tag used during build and import | +| `output` | Path to the OCI archive to create | +| `command` | The command that runs inside the container | +| `containerfile` | The Dockerfile/Podmanfile recipe | -## The generated entrypoint +## Optional fields -Every Bunkerbox image uses a generated entrypoint called: +| Field | Purpose | +|---|---| +| `overwrite` | If `true`, replaces an existing archive instead of erroring | +| `build_args` | Key-value pairs passed to the container build | +| `hooks` | Shell commands that run at specific points (see [Hooks](hooks.md)) | +| `files` | Extra files to copy into the build context | +| `runtime` | Auto-generates a runtime config file | -```text -bunker-entrypoint +## Container recipe + +Your container recipe needs four things: + +**1. A musl base image:** + +``` +FROM docker.io/library/alpine:3.22 ``` -Your container recipe must copy it into the image and use it as the entrypoint: +**2. The generated entrypoint script:** -```text +``` COPY bunker-entrypoint /usr/local/bin/bunker-entrypoint +RUN chmod 0755 /usr/local/bin/bunker-entrypoint ENTRYPOINT ["/usr/local/bin/bunker-entrypoint"] ``` -This generated entrypoint is important. It sets up the app home directory, runs hooks, starts the app command, and preserves the app exit status. All session management (loop mount, recovery, sync) happens on the host — the entrypoint itself is a thin wrapper. +**3. The Bunkerbox helper binaries:** -## Fields +``` +COPY bunkerbox-vscomm /usr/local/bunkerbox/bin/bunkerbox-vscomm +COPY bunkerbox-status /usr/local/bunkerbox/bin/bunkerbox-status +``` -`name` is a short name for the image config. `image` is the local image tag used while building and importing. `output` is the OCI archive that will be written. `command` is the app command that runs inside the container. `containerfile` is the actual container build recipe. +**4. A directory for the workspace and home:** -Optional fields add behavior. `overwrite` allows replacing an existing archive. `build_args` passes values into the container build. `hooks` adds lifecycle shell snippets. `files` adds extra files to the build context. `runtime` auto-generates a runtime config file. +``` +RUN mkdir -p /workspace /home/bunkerbox /usr/local/bunkerbox/bin \ + && chmod 0777 /workspace /home/bunkerbox /usr/local/bunkerbox/bin +``` -## Runtime section +## Runtime settings -The image author knows what runtime settings the tool needs (workspace mode, home persistence, network rules, and which files hold secrets). Define them once in the image config with the `runtime:` section, and the builder writes a runtime config automatically. +Add a `runtime:` section to define how the tool should run on the user's machine: ```yaml runtime: @@ -67,17 +90,14 @@ runtime: allow: - api.deepseek.com encrypt: - - ".local/share/opencode/auth.json" - - ".local/share/opencode/account.json" + - ".local/share/my-app/auth.json" ``` -When the builder finishes, it writes `.conf` next to the OCI archive. The conf includes `oci` (the archive path) and `image` (the image tag) merged with everything from the `runtime:` section. - -For the OpenCode config above, the builder would produce: +The builder writes a `.conf` file next to the archive, merging your `runtime:` settings with the archive path and image tag. For the OpenCode example above, you get: -```text +``` bunkerbox-opencode-1.17.18.oci opencode.conf ``` -The packager installs both files. The `opencode.conf` is ready to use — no hand-editing needed. +Both files go into the package install. No hand-editing needed. See [Runtime config](runtime.md) for all options. diff --git a/docs/guides/custom-images.md b/docs/guides/custom-images.md index ad2d42c..d8cca65 100644 --- a/docs/guides/custom-images.md +++ b/docs/guides/custom-images.md @@ -1,49 +1,81 @@ -# Custom images +# Creating a custom image -A custom image config lets you package another tool for Bunkerbox. +Packaging a new tool for Bunkerbox means writing an image config. It's a YAML file that describes your tool, the container it runs in, and how it should behave at runtime. -Start by adding a config file under `images/`. The existing `images/opencode.conf` is the best example to copy from because it shows the expected shape: image name, output archive, command, hooks section, and container recipe. +## Start from an example -Build a config with: +Copy one of the existing configs from `images/`. The simplest one to start from is the kilocode config: ```sh -make image IMAGE=images/opencode.conf +cp images/kilocode.conf images/my-tool.conf ``` -For your own tool, replace the path with your new config file. +## The config structure -## What the config must do +Every image config needs these parts: -The config must define the command that should run inside the container. It must also provide a container recipe that installs the tool. +| Section | What it does | +|---|---| +| `name`, `image`, `output` | Identifies the tool and names the output archive | +| `command` | The command that runs inside the container | +| `containerfile` | The recipe that installs your tool | +| `runtime` | Settings for how the tool runs on the user's machine | -The container recipe must copy the generated Bunkerbox entrypoint into the image: +## The container recipe -```text -COPY bunker-entrypoint /usr/local/bin/bunker-entrypoint -``` +Your container recipe (`containerfile`) must install your tool and set up the Bunkerbox runtime. Here's the pattern every mature image uses: + +```dockerfile +FROM docker.io/library/alpine:3.22 + +ARG MY_TOOL_VERSION -It must then use that generated file as the entrypoint: +# Install your tool and its dependencies +RUN apk add --no-cache bash ca-certificates curl git \ + && curl -fsSL "https://example.com/my-tool-linux-musl.tar.gz" \ + -o /tmp/my-tool.tar.gz \ + && tar -xzf /tmp/my-tool.tar.gz -C /usr/local/bin \ + && chmod 0755 /usr/local/bin/my-tool-app \ + && rm -f /tmp/my-tool.tar.gz -```text +# Required Bunkerbox directories +RUN mkdir -p /workspace /home/bunkerbox /usr/local/bunkerbox/bin \ + && chmod 0777 /workspace /home/bunkerbox /usr/local/bunkerbox/bin + +# Required Bunkerbox files +COPY bunker-entrypoint /usr/local/bin/bunker-entrypoint +RUN chmod 0755 /usr/local/bin/bunker-entrypoint +COPY bunkerbox-vscomm /usr/local/bunkerbox/bin/bunkerbox-vscomm +COPY bunkerbox-status /usr/local/bunkerbox/bin/bunkerbox-status + +ENV HOME=/home/bunkerbox +WORKDIR /workspace ENTRYPOINT ["/usr/local/bin/bunker-entrypoint"] ``` -That entrypoint is what makes persistence and hooks work. +Key points: +- Use an `x86_64` musl-based image like `alpine:3.22` +- Copy `bunker-entrypoint` and set it as `ENTRYPOINT` +- Copy `bunkerbox-vscomm` and `bunkerbox-status` — they go in `/usr/local/bunkerbox/bin/` +- Create `/workspace` and `/home/bunkerbox` with write permissions -## Hooks +## Adding hooks -If the tool needs setup or cleanup, add hooks to the image config. +Hooks run shell commands at specific points during your tool's lifecycle: ```yaml hooks: before-app: | - echo "starting app" + bunkerbox-status status set 'Launching my-tool...' + bunkerbox-status popup hide "SEC_2" ``` -Hooks run inside the container, not on the host. +During startup, the status overlay shows "Launching my-tool..." and then fades after 2 seconds while your tool is already running. + +For more details on hooks and what commands you can run, see [Hooks](../config/hooks.md). -## Packaging the tool +## Packaging -After the image exists, packaging needs a runtime config and an app command. The runtime config tells Bunkerbox where the OCI archive is and how to run it. The app command is usually a symlink to the Bunkerbox binary. +After building, you'll have two files: the OCI archive and a runtime config. Both go into the system package. The runtime config tells Bunkerbox how to run your tool — workspace mode, network settings, which files to encrypt. Users invoke your tool through a symlink that points at the Bunkerbox binary. -Read [Packaging](packaging.md) for the full model. +Read [Packaging](packaging.md) for the full distribution model. From 1e6ab0204ca20c85ef93c078fb5a22629913402b Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Thu, 30 Jul 2026 01:34:57 +0200 Subject: [PATCH 23/42] Fix first run --- src/kata.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/kata.rs b/src/kata.rs index e0d7faf..2b84c91 100644 --- a/src/kata.rs +++ b/src/kata.rs @@ -122,13 +122,17 @@ pub fn run( let session_dir: Option = if session_mb > 0 { if let Some(ref hp) = home_path { let home_size = dir_size(hp); - let measured_mb = ((home_size as f64 * 1.2) / (1024.0 * 1024.0)).ceil() as u32; - if config.session_mb.is_none() || measured_mb > session_mb { - session_mb = measured_mb.max(50); - crate::logging::log(&format!("home dir is {} MB, auto-sized session to {} MB", home_size / (1024 * 1024), session_mb)); + if home_size == 0 { + None + } else { + let measured_mb = ((home_size as f64 * 1.2) / (1024.0 * 1024.0)).ceil() as u32; + if config.session_mb.is_none() || measured_mb > session_mb { + session_mb = measured_mb.max(50); + crate::logging::log(&format!("home dir is {} MB, auto-sized session to {} MB", home_size / (1024 * 1024), session_mb)); + } + crate::logging::log("Setting up session image..."); + Some(setup_session(hp, session_mb, uid, gid)?) } - crate::logging::log("Setting up session image..."); - Some(setup_session(hp, session_mb, uid, gid)?) } else { None } From d79352d108ef9b4c9f7e36a0ca76c9dc62f27bde Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Thu, 30 Jul 2026 18:38:41 +0200 Subject: [PATCH 24/42] Updates on vt100 parsing to support required sequence --- Cargo.lock | 38 ++++++++---------------------- Cargo.toml | 2 +- src/bin/bunkerbox-image.rs | 12 ++++++++-- src/kata.rs | 1 - src/tui.rs | 47 +++++++++++++++++++++++++++++++++++++- 5 files changed, 67 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 847fe1c..4ff9f23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1390,7 +1390,7 @@ dependencies = [ "thiserror 2.0.19", "unicode-segmentation", "unicode-truncate", - "unicode-width 0.2.2", + "unicode-width", ] [[package]] @@ -1413,7 +1413,7 @@ source = "git+https://github.com/tinythings/ratatui-glamour.git?rev=1545394#1545 dependencies = [ "crossterm 0.28.1", "ratatui", - "unicode-width 0.2.2", + "unicode-width", ] [[package]] @@ -1475,7 +1475,7 @@ dependencies = [ "strum", "time", "unicode-segmentation", - "unicode-width 0.2.2", + "unicode-width", ] [[package]] @@ -2007,15 +2007,9 @@ checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ "itertools", "unicode-segmentation", - "unicode-width 0.2.2", + "unicode-width", ] -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - [[package]] name = "unicode-width" version = "0.2.2" @@ -2075,35 +2069,23 @@ dependencies = [ [[package]] name = "vt100" -version = "0.15.2" +version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84cd863bf0db7e392ba3bd04994be3473491b31e66340672af5d11943c6274de" +checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9" dependencies = [ "itoa", - "log", - "unicode-width 0.1.14", + "unicode-width", "vte", ] [[package]] name = "vte" -version = "0.11.1" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" dependencies = [ "arrayvec", - "utf8parse", - "vte_generate_state_changes", -] - -[[package]] -name = "vte_generate_state_changes" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e" -dependencies = [ - "proc-macro2", - "quote", + "memchr", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 6816112..f87b1ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ tokio = { version = "1", features = ["rt-multi-thread", "net", "macros", "sync", tokio-vsock = "0.7" ratatui = "0.30" crossterm = "0.28" -vt100 = "0.15" +vt100 = "0.16" ratatui-glamour = { git = "https://github.com/tinythings/ratatui-glamour.git", rev = "1545394" } [dev-dependencies] diff --git a/src/bin/bunkerbox-image.rs b/src/bin/bunkerbox-image.rs index ccd8eed..f1a7289 100644 --- a/src/bin/bunkerbox-image.rs +++ b/src/bin/bunkerbox-image.rs @@ -320,12 +320,20 @@ fn dist_dir() -> Result { fn find_vscomm_binary() -> Result { let path = dist_dir()?.join("bunkerbox-vscomm"); - if path.is_file() { Ok(path) } else { Err("bunkerbox-vscomm not found in target/dist/. Run: make dev".into()) } + if path.is_file() { + Ok(path) + } else { + Err("bunkerbox-vscomm not found in target/dist/. Run: make dev".into()) + } } fn find_status_binary() -> Result { let path = dist_dir()?.join("bunkerbox-status"); - if path.is_file() { Ok(path) } else { Err("bunkerbox-status not found in target/dist/. Run: make dev".into()) } + if path.is_file() { + Ok(path) + } else { + Err("bunkerbox-status not found in target/dist/. Run: make dev".into()) + } } fn podman_build(config: &ImageConfig, build_dir: &Path) -> Result<(), String> { diff --git a/src/kata.rs b/src/kata.rs index 2b84c91..015d3f0 100644 --- a/src/kata.rs +++ b/src/kata.rs @@ -43,7 +43,6 @@ pub fn run( config: &RuntimeConfig, workspace: WorkspaceHandle, container_name: &str, _share_dir: &Path, app_name: &str, vsock_enabled: bool, _status_fd: RawFd, ) -> Result<(), String> { - if !config.oci.is_file() { return Err(format!("OCI archive not found: {}", config.oci.display())); } diff --git a/src/tui.rs b/src/tui.rs index 1453ca9..4cf5179 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -141,10 +141,12 @@ pub fn dispatch_ui_command(state: &mut OverlayState, widget: &str, command: &str /// character set translation and HVP-to-CUP normalization. struct Term { parser: vt100::Parser, + responses: Vec>, escape_state: EscapeState, g0_dec_special_graphics: bool, g1_dec_special_graphics: bool, using_g1_charset: bool, + application_cursor_keys: bool, } #[derive(Clone, Copy)] @@ -162,10 +164,12 @@ impl Term { fn new(rows: u16, cols: u16) -> Self { Self { parser: vt100::Parser::new(rows, cols, 0), + responses: Vec::new(), escape_state: EscapeState::Ground, g0_dec_special_graphics: false, g1_dec_special_graphics: false, using_g1_charset: false, + application_cursor_keys: false, } } @@ -176,15 +180,48 @@ impl Term { /// Resizes the terminal grid after a window resize event. fn set_size(&mut self, rows: u16, cols: u16) { - self.parser.set_size(rows, cols); + self.parser.screen_mut().set_size(rows, cols); } /// Feeds raw bytes through DEC translation then into the vt100 parser. fn process(&mut self, bytes: &[u8]) { + if bytes.contains(&0x1b) { + self.handle_queries(bytes); + self.handle_cursor_mode(bytes); + } let translated = self.translate_dec_special_graphics(bytes); self.parser.process(&translated); } + fn drain_responses(&mut self) -> Vec> { + std::mem::take(&mut self.responses) + } + + #[allow(dead_code)] + fn application_cursor_keys(&self) -> bool { + self.application_cursor_keys + } + + fn handle_cursor_mode(&mut self, bytes: &[u8]) { + if bytes.windows(5).any(|w| w == b"\x1b[?1h") { + self.application_cursor_keys = true; + } + if bytes.windows(5).any(|w| w == b"\x1b[?1l") { + self.application_cursor_keys = false; + } + } + + fn handle_queries(&mut self, bytes: &[u8]) { + if bytes.windows(4).any(|w| w == b"\x1b[6n") { + let (cur_row, cur_col) = self.parser.screen().cursor_position(); + self.responses.push(format!("\x1b[{};{}R", cur_row + 1, cur_col + 1).into_bytes()); + } + if bytes.windows(5).any(|w| w == b"\x1b[18t") { + let (rows, cols) = self.parser.screen().size(); + self.responses.push(format!("\x1b[8;{};{}t", rows, cols).into_bytes()); + } + } + fn active_dec_special_graphics(&self) -> bool { if self.using_g1_charset { self.g1_dec_special_graphics @@ -412,6 +449,11 @@ where let n = unsafe { libc::read(master_fd, pty_buf.as_mut_ptr() as *mut libc::c_void, pty_buf.len()) }; if n > 0 { term.process(&pty_buf[..n as usize]); + for response in term.drain_responses() { + unsafe { + libc::write(master_fd, response.as_ptr() as *const libc::c_void, response.len()); + } + } pty_output = true; } else { hold_error_popup(&overlay, &mut terminal, term.screen()); @@ -557,6 +599,9 @@ fn render_frame(f: &mut Frame, screen: &vt100::Screen, overlay: &OverlayState) { if cell.bold() { style = style.add_modifier(Modifier::BOLD); } + if cell.dim() { + style = style.add_modifier(Modifier::DIM); + } if cell.italic() { style = style.add_modifier(Modifier::ITALIC); } From eab7a2224afdb7dafe897853da46f7d4c147b834 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Thu, 30 Jul 2026 18:40:15 +0200 Subject: [PATCH 25/42] stop tracking symlink --- demo/crush/crush | 1 - 1 file changed, 1 deletion(-) delete mode 120000 demo/crush/crush diff --git a/demo/crush/crush b/demo/crush/crush deleted file mode 120000 index 7744ad2..0000000 --- a/demo/crush/crush +++ /dev/null @@ -1 +0,0 @@ -/home/boma6672/work/bunker-project/bunkerbox/target/debug/bunkerbox \ No newline at end of file From 3ea14efc74c2bab12fdbd54436a272b2e42aa226 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Thu, 30 Jul 2026 18:40:37 +0200 Subject: [PATCH 26/42] stop tracking crush symlink --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 7577132..86f8aff 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ kata-static-*.tar.zst /demo/netdebug/netdebug /demo/opencode/opencode .bunkerbox/ +demo/crush/crush From 3e25ebde385d7a0325a5207249abcde0cd35fbb2 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Thu, 30 Jul 2026 19:42:53 +0200 Subject: [PATCH 27/42] Add UTF locale, update netdebug image adding complex rendering apps --- demo/netdebug/run-demo.sh | 6 ++++++ images/crush.conf | 3 +++ images/kilocode.conf | 3 +++ images/opencode.conf | 3 +++ 4 files changed, 15 insertions(+) diff --git a/demo/netdebug/run-demo.sh b/demo/netdebug/run-demo.sh index 841c64d..c32288c 100755 --- a/demo/netdebug/run-demo.sh +++ b/demo/netdebug/run-demo.sh @@ -114,6 +114,7 @@ files: echo "Interactive shell." echo + echo "To test TUI rendering: btop or htop" echo "To test VSOCK: bunkerbox-status popup info \"\" \"hello from vm\"" exec /bin/sh @@ -122,14 +123,19 @@ containerfile: | RUN apk add --no-cache \ bind-tools \ + btop \ busybox-extras \ ca-certificates \ curl \ + htop \ iproute2 \ iputils \ netcat-openbsd \ + musl-locales \ openssl + ENV LANG=C.UTF-8 + RUN mkdir -p /workspace /usr/local/bunkerbox/bin \ && chmod 0777 /workspace diff --git a/images/crush.conf b/images/crush.conf index ed52c71..60854be 100644 --- a/images/crush.conf +++ b/images/crush.conf @@ -44,6 +44,7 @@ containerfile: | ncurses-terminfo-base \ openssh-client \ ripgrep \ + musl-locales \ && curl -fsSL \ "https://github.com/charmbracelet/crush/releases/download/v${CRUSH_VERSION}/crush_${CRUSH_VERSION}_Linux_x86_64.tar.gz" \ -o /tmp/crush.tar.gz \ @@ -52,6 +53,8 @@ containerfile: | && rm -f /tmp/crush.tar.gz \ && crush --version + ENV LANG=C.UTF-8 + RUN mkdir -p /workspace /home/bunkerbox /usr/local/bunkerbox/bin \ && chmod 0777 /workspace /home/bunkerbox /usr/local/bunkerbox/bin diff --git a/images/kilocode.conf b/images/kilocode.conf index 9e77ddd..605896c 100644 --- a/images/kilocode.conf +++ b/images/kilocode.conf @@ -44,6 +44,7 @@ containerfile: | ncurses-terminfo-base \ openssh-client \ ripgrep \ + musl-locales \ && curl -fsSL \ "https://github.com/Kilo-Org/kilocode/releases/download/v${KILOCODE_VERSION}/kilo-linux-x64-baseline-musl.tar.gz" \ -o /tmp/kilocode.tar.gz \ @@ -52,6 +53,8 @@ containerfile: | && rm -f /tmp/kilocode.tar.gz \ && kilo --version + ENV LANG=C.UTF-8 + RUN mkdir -p /workspace /home/bunkerbox /usr/local/bunkerbox/bin \ && chmod 0777 /workspace /home/bunkerbox /usr/local/bunkerbox/bin diff --git a/images/opencode.conf b/images/opencode.conf index c794e6d..efe51bc 100644 --- a/images/opencode.conf +++ b/images/opencode.conf @@ -44,6 +44,7 @@ containerfile: | ncurses-terminfo-base \ openssh-client \ ripgrep \ + musl-locales \ && curl -fsSL \ "https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/opencode-linux-x64-baseline-musl.tar.gz" \ -o /tmp/opencode.tar.gz \ @@ -52,6 +53,8 @@ containerfile: | && rm -f /tmp/opencode.tar.gz \ && opencode --version + ENV LANG=C.UTF-8 + RUN mkdir -p /workspace /home/bunkerbox /usr/local/bunkerbox/bin \ && chmod 0777 /workspace /home/bunkerbox /usr/local/bunkerbox/bin From 679765a729c7e27aec6103193f14d34b84db9780 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Thu, 30 Jul 2026 19:57:47 +0200 Subject: [PATCH 28/42] Fix symbols throughput --- src/kata.rs | 21 ++++- src/tui.rs | 224 ++++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 193 insertions(+), 52 deletions(-) diff --git a/src/kata.rs b/src/kata.rs index 015d3f0..29b34fa 100644 --- a/src/kata.rs +++ b/src/kata.rs @@ -271,7 +271,7 @@ pub fn run( crate::logging::log("Connected"); - let result = run_command("sudo", &args); + let result = run_command_interactive("sudo", &args); crate::logging::log("Container stopped"); if bridge_firewall { @@ -587,6 +587,25 @@ fn run_command(program: &str, args: &[&str]) -> Result<(), String> { Ok(()) } +/// Runs an interactive command with all stdio attached to the terminal. +/// Full-screen TUIs can write control sequences to stderr, so this path must +/// not line-buffer or re-emit stderr. +fn run_command_interactive(program: &str, args: &[&str]) -> Result<(), String> { + let status = Command::new(program) + .args(args) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .map_err(|err| format!("failed to run {program}: {err}"))?; + + if !status.success() { + return Err(format!("command failed with status {status}: {program}")); + } + + Ok(()) +} + /// Runs a command silently (no stdio) and ignores non-zero exit codes, only reporting spawn errors. fn run_command_allow_failure(program: &str, args: &[&str]) -> Result<(), String> { Command::new(program) diff --git a/src/tui.rs b/src/tui.rs index 4cf5179..492da42 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -6,7 +6,7 @@ use std::sync::{Arc, Mutex}; use std::time::Instant; use crossterm::cursor; -use crossterm::event::{self}; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; use crossterm::ExecutableCommand; use ratatui::backend::CrosstermBackend; @@ -147,6 +147,7 @@ struct Term { g1_dec_special_graphics: bool, using_g1_charset: bool, application_cursor_keys: bool, + csi_bytes: Vec, } #[derive(Clone, Copy)] @@ -170,6 +171,7 @@ impl Term { g1_dec_special_graphics: false, using_g1_charset: false, application_cursor_keys: false, + csi_bytes: Vec::new(), } } @@ -185,12 +187,7 @@ impl Term { /// Feeds raw bytes through DEC translation then into the vt100 parser. fn process(&mut self, bytes: &[u8]) { - if bytes.contains(&0x1b) { - self.handle_queries(bytes); - self.handle_cursor_mode(bytes); - } - let translated = self.translate_dec_special_graphics(bytes); - self.parser.process(&translated); + self.process_bytes(bytes); } fn drain_responses(&mut self) -> Vec> { @@ -202,26 +199,6 @@ impl Term { self.application_cursor_keys } - fn handle_cursor_mode(&mut self, bytes: &[u8]) { - if bytes.windows(5).any(|w| w == b"\x1b[?1h") { - self.application_cursor_keys = true; - } - if bytes.windows(5).any(|w| w == b"\x1b[?1l") { - self.application_cursor_keys = false; - } - } - - fn handle_queries(&mut self, bytes: &[u8]) { - if bytes.windows(4).any(|w| w == b"\x1b[6n") { - let (cur_row, cur_col) = self.parser.screen().cursor_position(); - self.responses.push(format!("\x1b[{};{}R", cur_row + 1, cur_col + 1).into_bytes()); - } - if bytes.windows(5).any(|w| w == b"\x1b[18t") { - let (rows, cols) = self.parser.screen().size(); - self.responses.push(format!("\x1b[8;{};{}t", rows, cols).into_bytes()); - } - } - fn active_dec_special_graphics(&self) -> bool { if self.using_g1_charset { self.g1_dec_special_graphics @@ -231,8 +208,10 @@ impl Term { } /// Translates `\e(0` DEC Special Graphics characters to Unicode - /// box-drawing glyphs and normalizes HVP (`CSI … f`) to CUP (`CSI … H`). - fn translate_dec_special_graphics(&mut self, bytes: &[u8]) -> Vec { + /// box-drawing glyphs, normalizes HVP (`CSI … f`) to CUP (`CSI … H`), + /// and answers stream-split terminal queries after preceding bytes have + /// been parsed. + fn process_bytes(&mut self, bytes: &[u8]) { let mut translated = Vec::with_capacity(bytes.len()); for &byte in bytes { @@ -260,6 +239,7 @@ impl Term { } b'[' => { self.escape_state = EscapeState::Csi; + self.csi_bytes.clear(); translated.push(0x1b); translated.push(byte); } @@ -283,6 +263,7 @@ impl Term { self.escape_state = EscapeState::Ground; } EscapeState::Csi => { + self.csi_bytes.push(byte); if byte == b'f' { translated.push(b'H'); } else { @@ -290,6 +271,10 @@ impl Term { } if (0x40..=0x7e).contains(&byte) { self.escape_state = EscapeState::Ground; + if self.handle_csi_complete(&mut translated) { + translated.clear(); + } + self.csi_bytes.clear(); } } EscapeState::String => { @@ -307,7 +292,31 @@ impl Term { } } - translated + if !translated.is_empty() { + self.parser.process(&translated); + } + } + + fn handle_csi_complete(&mut self, translated: &mut Vec) -> bool { + match self.csi_bytes.as_slice() { + b"?1h" => self.application_cursor_keys = true, + b"?1l" => self.application_cursor_keys = false, + b"6n" => { + self.parser.process(translated); + let (cur_row, cur_col) = self.parser.screen().cursor_position(); + self.responses.push(format!("\x1b[{};{}R", cur_row + 1, cur_col + 1).into_bytes()); + return true; + } + b"18t" => { + self.parser.process(translated); + let (rows, cols) = self.parser.screen().size(); + self.responses.push(format!("\x1b[8;{};{}t", rows, cols).into_bytes()); + return true; + } + _ => {} + } + + false } } @@ -398,7 +407,6 @@ where let mut terminal = Terminal::new(backend).map_err(|e| format!("terminal: {e}"))?; let mut pty_buf = [0u8; 4096]; - let mut stdin_buf = [0u8; 1]; let mut last_rows = rows; let mut last_cols = cols; @@ -462,29 +470,17 @@ where } if fds[1].revents & libc::POLLIN != 0 { - let n = unsafe { libc::read(stdin_fd, stdin_buf.as_mut_ptr() as *mut libc::c_void, 1usize) }; - if n > 0 { - let byte = stdin_buf[0]; + if let Ok(Event::Key(key)) = event::read() { + if key.kind != KeyEventKind::Press { + continue; + } + let is_password = overlay.lock().is_ok_and(|s| matches!(s.popup.content, popup::PopupContent::Password { .. })); if is_password { - let mut state = overlay.lock().unwrap(); - if byte == 0x0d { - if let Some(password) = state.popup.take_password() { - let mut response = password.into_bytes(); - response.push(b'\n'); - state.popup.hide(); - unsafe { - libc::write(status_fd, response.as_ptr() as *const libc::c_void, response.len()); - } - } - } else if byte == 0x7f { - state.popup.pop_char(); - } else if byte >= 0x20 && byte != 0x7f { - state.popup.push_char(byte as char); - } - } else { + handle_password_key(&overlay, status_fd, key); + } else if let Some(bytes) = key_to_bytes(&key, term.application_cursor_keys()) { unsafe { - libc::write(master_fd, stdin_buf.as_ptr() as *const libc::c_void, 1usize); + libc::write(master_fd, bytes.as_ptr() as *const libc::c_void, bytes.len()); } } } @@ -562,6 +558,132 @@ where Ok(()) } +fn handle_password_key(overlay: &Arc>, status_fd: RawFd, key: KeyEvent) { + let mut state = overlay.lock().unwrap(); + match key.code { + KeyCode::Enter => { + if let Some(password) = state.popup.take_password() { + let mut response = password.into_bytes(); + response.push(b'\n'); + state.popup.hide(); + unsafe { + libc::write(status_fd, response.as_ptr() as *const libc::c_void, response.len()); + } + } + } + KeyCode::Backspace => state.popup.pop_char(), + KeyCode::Char(c) if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => state.popup.push_char(c), + _ => {} + } +} + +fn key_to_bytes(key: &KeyEvent, app_cursor: bool) -> Option> { + match key.code { + KeyCode::Char(c) => { + if key.modifiers.contains(KeyModifiers::CONTROL) { + if c.is_ascii_alphabetic() { + Some(vec![(c.to_ascii_lowercase() as u8) & 0x1f]) + } else { + None + } + } else if key.modifiers.contains(KeyModifiers::ALT) { + let mut v = vec![0x1b]; + let mut buf = [0u8; 4]; + let len = c.encode_utf8(&mut buf).len(); + v.extend_from_slice(&buf[..len]); + Some(v) + } else { + let mut buf = [0u8; 4]; + let len = c.encode_utf8(&mut buf).len(); + Some(buf[..len].to_vec()) + } + } + KeyCode::Enter => Some(vec![b'\r']), + KeyCode::Backspace => Some(vec![0x7f]), + KeyCode::Tab => Some(vec![b'\t']), + KeyCode::Esc => Some(vec![0x1b]), + KeyCode::Up => Some(if app_cursor { b"\x1bOA".to_vec() } else { b"\x1b[A".to_vec() }), + KeyCode::Down => Some(if app_cursor { b"\x1bOB".to_vec() } else { b"\x1b[B".to_vec() }), + KeyCode::Right => Some(if app_cursor { b"\x1bOC".to_vec() } else { b"\x1b[C".to_vec() }), + KeyCode::Left => Some(if app_cursor { b"\x1bOD".to_vec() } else { b"\x1b[D".to_vec() }), + KeyCode::Home => Some(if app_cursor { b"\x1bOH".to_vec() } else { b"\x1b[H".to_vec() }), + KeyCode::End => Some(if app_cursor { b"\x1bOF".to_vec() } else { b"\x1b[F".to_vec() }), + KeyCode::PageUp => Some(b"\x1b[5~".to_vec()), + KeyCode::PageDown => Some(b"\x1b[6~".to_vec()), + KeyCode::Delete => Some(b"\x1b[3~".to_vec()), + KeyCode::Insert => Some(b"\x1b[2~".to_vec()), + KeyCode::F(n) => fn_key(n), + _ => None, + } +} + +fn fn_key(n: u8) -> Option> { + match n { + 1 => Some(b"\x1bOP".to_vec()), + 2 => Some(b"\x1bOQ".to_vec()), + 3 => Some(b"\x1bOR".to_vec()), + 4 => Some(b"\x1bOS".to_vec()), + 5 => Some(b"\x1b[15~".to_vec()), + 6 => Some(b"\x1b[17~".to_vec()), + 7 => Some(b"\x1b[18~".to_vec()), + 8 => Some(b"\x1b[19~".to_vec()), + 9 => Some(b"\x1b[20~".to_vec()), + 10 => Some(b"\x1b[21~".to_vec()), + 11 => Some(b"\x1b[23~".to_vec()), + 12 => Some(b"\x1b[24~".to_vec()), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::Term; + + #[test] + fn cursor_report_uses_position_after_prior_bytes_in_same_chunk() { + let mut term = Term::new(24, 80); + + term.process(b"\x1b[10;20H\x1b[6n"); + + assert_eq!(term.drain_responses(), vec![b"\x1b[10;20R".to_vec()]); + } + + #[test] + fn cursor_report_handles_split_query() { + let mut term = Term::new(24, 80); + + term.process(b"\x1b[10;20H\x1b["); + assert!(term.drain_responses().is_empty()); + term.process(b"6n"); + + assert_eq!(term.drain_responses(), vec![b"\x1b[10;20R".to_vec()]); + } + + #[test] + fn cursor_mode_handles_split_sequences() { + let mut term = Term::new(24, 80); + + term.process(b"\x1b[?"); + term.process(b"1h"); + assert!(term.application_cursor_keys()); + + term.process(b"\x1b[?1"); + term.process(b"l"); + assert!(!term.application_cursor_keys()); + } + + #[test] + fn window_size_report_handles_split_query() { + let mut term = Term::new(24, 80); + + term.process(b"\x1b[18"); + assert!(term.drain_responses().is_empty()); + term.process(b"t"); + + assert_eq!(term.drain_responses(), vec![b"\x1b[8;24;80t".to_vec()]); + } +} + /// Converts a [`vt100::Color`] to a [`ratatui::style::Color`], preserving /// 24-bit RGB, 256-color indexed palette, and terminal default. fn to_ratatui_color(c: vt100::Color) -> Color { From 451d60481ec5d3004af4f2702f5b848b91cac609 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Thu, 30 Jul 2026 19:58:26 +0200 Subject: [PATCH 29/42] Linter --- src/tui.rs | 100 ++++++++++++++++++++++++++--------------------------- 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/src/tui.rs b/src/tui.rs index 492da42..2a48fd7 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -635,55 +635,6 @@ fn fn_key(n: u8) -> Option> { } } -#[cfg(test)] -mod tests { - use super::Term; - - #[test] - fn cursor_report_uses_position_after_prior_bytes_in_same_chunk() { - let mut term = Term::new(24, 80); - - term.process(b"\x1b[10;20H\x1b[6n"); - - assert_eq!(term.drain_responses(), vec![b"\x1b[10;20R".to_vec()]); - } - - #[test] - fn cursor_report_handles_split_query() { - let mut term = Term::new(24, 80); - - term.process(b"\x1b[10;20H\x1b["); - assert!(term.drain_responses().is_empty()); - term.process(b"6n"); - - assert_eq!(term.drain_responses(), vec![b"\x1b[10;20R".to_vec()]); - } - - #[test] - fn cursor_mode_handles_split_sequences() { - let mut term = Term::new(24, 80); - - term.process(b"\x1b[?"); - term.process(b"1h"); - assert!(term.application_cursor_keys()); - - term.process(b"\x1b[?1"); - term.process(b"l"); - assert!(!term.application_cursor_keys()); - } - - #[test] - fn window_size_report_handles_split_query() { - let mut term = Term::new(24, 80); - - term.process(b"\x1b[18"); - assert!(term.drain_responses().is_empty()); - term.process(b"t"); - - assert_eq!(term.drain_responses(), vec![b"\x1b[8;24;80t".to_vec()]); - } -} - /// Converts a [`vt100::Color`] to a [`ratatui::style::Color`], preserving /// 24-bit RGB, 256-color indexed palette, and terminal default. fn to_ratatui_color(c: vt100::Color) -> Color { @@ -735,7 +686,7 @@ fn render_frame(f: &mut Frame, screen: &vt100::Screen, overlay: &OverlayState) { } let ch = cell.contents(); - let display: &str = if ch.is_empty() { " " } else { &ch }; + let display: &str = if ch.is_empty() { " " } else { ch }; if let Some(c) = buf.cell_mut((x, y)) { c.set_symbol(display); @@ -773,3 +724,52 @@ fn render_frame(f: &mut Frame, screen: &vt100::Screen, overlay: &OverlayState) { f.set_cursor_position((area.x + cursor_col, area.y + cursor_row)); } } + +#[cfg(test)] +mod tests { + use super::Term; + + #[test] + fn cursor_report_uses_position_after_prior_bytes_in_same_chunk() { + let mut term = Term::new(24, 80); + + term.process(b"\x1b[10;20H\x1b[6n"); + + assert_eq!(term.drain_responses(), vec![b"\x1b[10;20R".to_vec()]); + } + + #[test] + fn cursor_report_handles_split_query() { + let mut term = Term::new(24, 80); + + term.process(b"\x1b[10;20H\x1b["); + assert!(term.drain_responses().is_empty()); + term.process(b"6n"); + + assert_eq!(term.drain_responses(), vec![b"\x1b[10;20R".to_vec()]); + } + + #[test] + fn cursor_mode_handles_split_sequences() { + let mut term = Term::new(24, 80); + + term.process(b"\x1b[?"); + term.process(b"1h"); + assert!(term.application_cursor_keys()); + + term.process(b"\x1b[?1"); + term.process(b"l"); + assert!(!term.application_cursor_keys()); + } + + #[test] + fn window_size_report_handles_split_query() { + let mut term = Term::new(24, 80); + + term.process(b"\x1b[18"); + assert!(term.drain_responses().is_empty()); + term.process(b"t"); + + assert_eq!(term.drain_responses(), vec![b"\x1b[8;24;80t".to_vec()]); + } +} From cf3929e1faa16a869fefbf42a001eb754b9cb72d Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Thu, 30 Jul 2026 21:43:55 +0200 Subject: [PATCH 30/42] Lintfix --- src/tui.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tui.rs b/src/tui.rs index 2a48fd7..5f5b68c 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -297,7 +297,7 @@ impl Term { } } - fn handle_csi_complete(&mut self, translated: &mut Vec) -> bool { + fn handle_csi_complete(&mut self, translated: &mut [u8]) -> bool { match self.csi_bytes.as_slice() { b"?1h" => self.application_cursor_keys = true, b"?1l" => self.application_cursor_keys = false, From 00a5a786d2a4431d6974ee6914f9c669547298b1 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Thu, 30 Jul 2026 22:06:53 +0200 Subject: [PATCH 31/42] Move tests outside --- src/sandbox/mod.rs | 81 ++-------------------------------------------- src/sandbox/ut.rs | 79 ++++++++++++++++++++++++++++++++++++++++++++ src/tui.rs | 49 ++-------------------------- src/tui_ut.rs | 45 ++++++++++++++++++++++++++ 4 files changed, 128 insertions(+), 126 deletions(-) create mode 100644 src/sandbox/ut.rs create mode 100644 src/tui_ut.rs diff --git a/src/sandbox/mod.rs b/src/sandbox/mod.rs index b8283cc..aa3d1a2 100644 --- a/src/sandbox/mod.rs +++ b/src/sandbox/mod.rs @@ -124,82 +124,5 @@ fn get_builtin_profile(name: &str) -> Result<&str, String> { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_profile() { - let yaml = r#" -name: test -bin: - ls: /usr/bin/ls -ro: - - /lib -rw: - - "${HOME}/.cache" -env: - FOO: bar -network: none -shell: /bin/sh -"#; - let profile = parse_profile_yaml(yaml).unwrap(); - assert_eq!(profile.name, "test"); - assert_eq!(profile.bin.get("ls").unwrap(), &std::path::PathBuf::from("/usr/bin/ls")); - assert_eq!(profile.ro.len(), 1); - assert_eq!(profile.rw.len(), 1); - assert!(matches!(profile.network, NetworkMode::None)); - } - - #[test] - fn test_merge_profiles() { - let p1 = Profile { - name: "a".into(), - bin: { - let mut m = BTreeMap::new(); - m.insert("cmd1".into(), "/usr/bin/cmd1".into()); - m - }, - ro: vec!["/lib".into()], - rw: vec!["/cache".into()], - env: { - let mut m = BTreeMap::new(); - m.insert("A".into(), "1".into()); - m - }, - network: NetworkMode::None, - shell: "/bin/sh".into(), - }; - let p2 = Profile { - name: "b".into(), - bin: { - let mut m = BTreeMap::new(); - m.insert("cmd2".into(), "/usr/bin/cmd2".into()); - m - }, - ro: vec!["/usr/lib".into()], - rw: vec!["/other".into()], - env: { - let mut m = BTreeMap::new(); - m.insert("B".into(), "2".into()); - m - }, - network: NetworkMode::None, - shell: "/bin/dash".into(), - }; - let merged = MergedProfile::from_profiles(&[p1, p2]); - assert_eq!(merged.bin.len(), 2); - assert_eq!(merged.ro.len(), 2); - assert_eq!(merged.rw.len(), 2); - assert_eq!(merged.env.len(), 2); - assert_eq!(merged.shell, PathBuf::from("/bin/dash")); - assert_eq!(merged.name, "a+b"); - } - - #[test] - fn test_resolve_builtin() { - let profile = resolve_profile("make", std::path::Path::new("/nonexistent")).unwrap(); - assert_eq!(profile.name, "make"); - assert!(profile.bin.contains_key("make")); - assert!(profile.bin.contains_key("gcc")); - } -} +#[path = "ut.rs"] +mod sandbox_tests; diff --git a/src/sandbox/ut.rs b/src/sandbox/ut.rs new file mode 100644 index 0000000..7c602c0 --- /dev/null +++ b/src/sandbox/ut.rs @@ -0,0 +1,79 @@ +use super::*; +use std::collections::BTreeMap; +use std::path::PathBuf; + +#[test] +fn test_parse_profile() { + let yaml = r#" +name: test +bin: + ls: /usr/bin/ls +ro: + - /lib +rw: + - "${HOME}/.cache" +env: + FOO: bar +network: none +shell: /bin/sh +"#; + let profile = parse_profile_yaml(yaml).unwrap(); + assert_eq!(profile.name, "test"); + assert_eq!(profile.bin.get("ls").unwrap(), &std::path::PathBuf::from("/usr/bin/ls")); + assert_eq!(profile.ro.len(), 1); + assert_eq!(profile.rw.len(), 1); + assert!(matches!(profile.network, NetworkMode::None)); +} + +#[test] +fn test_merge_profiles() { + let p1 = Profile { + name: "a".into(), + bin: { + let mut m = BTreeMap::new(); + m.insert("cmd1".into(), "/usr/bin/cmd1".into()); + m + }, + ro: vec!["/lib".into()], + rw: vec!["/cache".into()], + env: { + let mut m = BTreeMap::new(); + m.insert("A".into(), "1".into()); + m + }, + network: NetworkMode::None, + shell: "/bin/sh".into(), + }; + let p2 = Profile { + name: "b".into(), + bin: { + let mut m = BTreeMap::new(); + m.insert("cmd2".into(), "/usr/bin/cmd2".into()); + m + }, + ro: vec!["/usr/lib".into()], + rw: vec!["/other".into()], + env: { + let mut m = BTreeMap::new(); + m.insert("B".into(), "2".into()); + m + }, + network: NetworkMode::None, + shell: "/bin/dash".into(), + }; + let merged = MergedProfile::from_profiles(&[p1, p2]); + assert_eq!(merged.bin.len(), 2); + assert_eq!(merged.ro.len(), 2); + assert_eq!(merged.rw.len(), 2); + assert_eq!(merged.env.len(), 2); + assert_eq!(merged.shell, PathBuf::from("/bin/dash")); + assert_eq!(merged.name, "a+b"); +} + +#[test] +fn test_resolve_builtin() { + let profile = resolve_profile("make", std::path::Path::new("/nonexistent")).unwrap(); + assert_eq!(profile.name, "make"); + assert!(profile.bin.contains_key("make")); + assert!(profile.bin.contains_key("gcc")); +} diff --git a/src/tui.rs b/src/tui.rs index 5f5b68c..c8aa87f 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -726,50 +726,5 @@ fn render_frame(f: &mut Frame, screen: &vt100::Screen, overlay: &OverlayState) { } #[cfg(test)] -mod tests { - use super::Term; - - #[test] - fn cursor_report_uses_position_after_prior_bytes_in_same_chunk() { - let mut term = Term::new(24, 80); - - term.process(b"\x1b[10;20H\x1b[6n"); - - assert_eq!(term.drain_responses(), vec![b"\x1b[10;20R".to_vec()]); - } - - #[test] - fn cursor_report_handles_split_query() { - let mut term = Term::new(24, 80); - - term.process(b"\x1b[10;20H\x1b["); - assert!(term.drain_responses().is_empty()); - term.process(b"6n"); - - assert_eq!(term.drain_responses(), vec![b"\x1b[10;20R".to_vec()]); - } - - #[test] - fn cursor_mode_handles_split_sequences() { - let mut term = Term::new(24, 80); - - term.process(b"\x1b[?"); - term.process(b"1h"); - assert!(term.application_cursor_keys()); - - term.process(b"\x1b[?1"); - term.process(b"l"); - assert!(!term.application_cursor_keys()); - } - - #[test] - fn window_size_report_handles_split_query() { - let mut term = Term::new(24, 80); - - term.process(b"\x1b[18"); - assert!(term.drain_responses().is_empty()); - term.process(b"t"); - - assert_eq!(term.drain_responses(), vec![b"\x1b[8;24;80t".to_vec()]); - } -} +#[path = "tui_ut.rs"] +mod tui_tests; diff --git a/src/tui_ut.rs b/src/tui_ut.rs new file mode 100644 index 0000000..0782a8d --- /dev/null +++ b/src/tui_ut.rs @@ -0,0 +1,45 @@ +use super::Term; + +#[test] +fn cursor_report_uses_position_after_prior_bytes_in_same_chunk() { + let mut term = Term::new(24, 80); + + term.process(b"\x1b[10;20H\x1b[6n"); + + assert_eq!(term.drain_responses(), vec![b"\x1b[10;20R".to_vec()]); +} + +#[test] +fn cursor_report_handles_split_query() { + let mut term = Term::new(24, 80); + + term.process(b"\x1b[10;20H\x1b["); + assert!(term.drain_responses().is_empty()); + term.process(b"6n"); + + assert_eq!(term.drain_responses(), vec![b"\x1b[10;20R".to_vec()]); +} + +#[test] +fn cursor_mode_handles_split_sequences() { + let mut term = Term::new(24, 80); + + term.process(b"\x1b[?"); + term.process(b"1h"); + assert!(term.application_cursor_keys()); + + term.process(b"\x1b[?1"); + term.process(b"l"); + assert!(!term.application_cursor_keys()); +} + +#[test] +fn window_size_report_handles_split_query() { + let mut term = Term::new(24, 80); + + term.process(b"\x1b[18"); + assert!(term.drain_responses().is_empty()); + term.process(b"t"); + + assert_eq!(term.drain_responses(), vec![b"\x1b[8;24;80t".to_vec()]); +} From fe7a0a69611f6ad15b49b68116877ea39891f49b Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 31 Jul 2026 17:16:34 +0200 Subject: [PATCH 32/42] Grab sudo password from the TUI popup --- src/main.rs | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/main.rs b/src/main.rs index 9b92ac1..929501a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -194,6 +194,48 @@ fn run_packaged_runtime(config: cfg::RuntimeConfig, workspace_override: Option Date: Fri, 31 Jul 2026 18:53:26 +0200 Subject: [PATCH 33/42] Update images --- images/crush.conf | 2 +- images/kilocode.conf | 2 +- images/opencode.conf | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/images/crush.conf b/images/crush.conf index 60854be..b785c24 100644 --- a/images/crush.conf +++ b/images/crush.conf @@ -16,7 +16,7 @@ hooks: # runs before app starts before-app: | bunkerbox-status status set 'Launching crush...' - bunkerbox-status popup hide "SEC_2" + bunkerbox-status popup hide --on-find-ascii # runs after app exits after-app: | diff --git a/images/kilocode.conf b/images/kilocode.conf index 605896c..8011060 100644 --- a/images/kilocode.conf +++ b/images/kilocode.conf @@ -16,7 +16,7 @@ hooks: # runs before app starts before-app: | bunkerbox-status status set 'Launching kilocode...' - bunkerbox-status popup hide "SEC_2" + bunkerbox-status popup hide --on-find-ascii # runs after app exits after-app: | diff --git a/images/opencode.conf b/images/opencode.conf index efe51bc..7aa9f20 100644 --- a/images/opencode.conf +++ b/images/opencode.conf @@ -16,7 +16,7 @@ hooks: # runs before app starts before-app: | bunkerbox-status status set 'Launching opencode...' - bunkerbox-status popup hide "SEC_2" + bunkerbox-status popup hide --on-find-ascii # runs after app exits after-app: | From 32c18c27861bcc3fabb26c3d091856bcd727faf3 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 31 Jul 2026 18:53:42 +0200 Subject: [PATCH 34/42] Rework logging, cleanup STDOUT --- src/bin/bunkerbox-status.rs | 70 ++++++++++++++++++++++++++++------- src/bin/bunkerbox-vscomm.rs | 6 --- src/clidef.rs | 2 + src/kata.rs | 38 ++++++++++++++++++- src/logging.rs | 28 ++++++++++---- src/main.rs | 22 ++++++++++- src/tui.rs | 74 ++++++++++++++++++++++++++++++++++++- src/wrap.rs | 2 +- 8 files changed, 212 insertions(+), 30 deletions(-) diff --git a/src/bin/bunkerbox-status.rs b/src/bin/bunkerbox-status.rs index ba008fa..f1643f9 100644 --- a/src/bin/bunkerbox-status.rs +++ b/src/bin/bunkerbox-status.rs @@ -19,23 +19,52 @@ fn main() { fn run() -> Result<(), String> { let args: Vec = env::args().collect(); - - let widget = args.get(1).ok_or_else(|| "usage: bunkerbox-status [options] [value]".to_string())?; - let command = args.get(2).ok_or_else(|| "usage: bunkerbox-status [options] [value]".to_string())?; - let (options, value) = match args.len() { - n if n >= 5 => (args[3].as_str(), args[4].as_str()), - 4 => { - if args[3].starts_with("SEC_") || args[3] == "ON_PTY" { - (args[3].as_str(), "") - } else { - ("", args[3].as_str()) - } + let mut find_pattern: Option = None; + let mut find_ascii = false; + let mut timeout_secs: Option = None; + + let mut i = 1; + let mut positional = vec![args[0].clone()]; + while i < args.len() { + if args[i] == "--help" { + print_help(&args); + std::process::exit(0); + } else if args[i] == "--on-find" && i + 1 < args.len() { + find_pattern = Some(args[i + 1].clone()); + i += 2; + } else if let Some(p) = args[i].strip_prefix("--on-find=") { + find_pattern = Some(p.to_string()); + i += 1; + } else if args[i] == "--on-find-ascii" { + find_ascii = true; + i += 1; + } else if args[i] == "--on-timeout" && i + 1 < args.len() { + timeout_secs = args[i + 1].parse::().ok(); + i += 2; + } else if let Some(s) = args[i].strip_prefix("--on-timeout=") { + timeout_secs = s.parse::().ok(); + i += 1; + } else { + positional.push(args[i].clone()); + i += 1; } - _ => ("", ""), + } + + let widget = positional.get(1).ok_or_else(|| "usage: bunkerbox-status [flags] [value]".to_string())?; + let command = positional.get(2).ok_or_else(|| "usage: bunkerbox-status [flags] [value]".to_string())?; + let pos_value = positional.get(3).map(|s| s.as_str()).unwrap_or(""); + + let options = timeout_secs.map(|s| format!("SEC_{s}")).unwrap_or_default(); + let value = if find_ascii { + "ASCII".to_string() + } else if let Some(p) = find_pattern { + p + } else { + pos_value.to_string() }; if !widget.is_empty() && !command.is_empty() { - let payload = vscomm::encode_ui_payload(widget, command, options, value); + let payload = vscomm::encode_ui_payload(widget, command, &options, &value); let frame = Frame::new(FrameType::UiCommand, payload); let mut stream = vsock_connect(HOST_CID, STATUS_PORT).map_err(|e| format!("vsock connect: {e}"))?; @@ -46,6 +75,21 @@ fn run() -> Result<(), String> { Ok(()) } +fn print_help(args: &[String]) { + let name = args.first().map(|s| s.as_str()).unwrap_or("bunkerbox-status"); + eprintln!("Usage: {name} [flags] [value]\n"); + eprintln!("Send a UI command to the bunkerbox TUI over vsock.\n"); + eprintln!("Flags:"); + eprintln!(" --on-find= Hide popup when exact text appears on screen"); + eprintln!(" --on-find-ascii Hide popup when [a-zA-Z0-9] appears on screen"); + eprintln!(" --on-timeout= Hide popup after N seconds"); + eprintln!(" --help Show this message\n"); + eprintln!("Examples:"); + eprintln!(" {name} popup hide --on-find-ascii"); + eprintln!(" {name} popup hide --on-find='$' --on-timeout=10"); + eprintln!(" {name} status set \"Running: unit tests\""); +} + fn vsock_connect(cid: u32, port: u32) -> io::Result { let delays = [100u64, 500, 1000]; for (i, &ms) in delays.iter().enumerate() { diff --git a/src/bin/bunkerbox-vscomm.rs b/src/bin/bunkerbox-vscomm.rs index 708ad0e..8174fcf 100644 --- a/src/bin/bunkerbox-vscomm.rs +++ b/src/bin/bunkerbox-vscomm.rs @@ -83,7 +83,6 @@ fn install_symlinks() -> Result<(), String> { let vscomm_path = env::current_exe().map_err(|e| format!("failed to locate vscomm binary: {e}"))?; - let mut installed = 0; for entry in &entries { let cmd = extract_command_name(entry); if cmd.is_empty() { @@ -97,11 +96,6 @@ fn install_symlinks() -> Result<(), String> { let _ = fs::remove_file(&target); } std::os::unix::fs::symlink(&vscomm_path, &target).map_err(|e| format!("symlink {cmd}: {e}"))?; - installed += 1; - } - - if installed > 0 { - eprintln!("bunkerbox-vscomm: installed {installed} passthrough commands"); } Ok(()) diff --git a/src/clidef.rs b/src/clidef.rs index bd466f9..b453d84 100644 --- a/src/clidef.rs +++ b/src/clidef.rs @@ -47,6 +47,8 @@ pub fn cli(version: &'static str) -> Command { .next_help_heading("Other") .arg(Arg::new("share").long("share").help("Override bunkerbox share directory")) .arg(Arg::new("workspace").long("workspace").value_parser(["share", "clone"]).help("Override workspace mode: share or clone")) + .arg(Arg::new("verbose").long("verbose").action(ArgAction::SetTrue).help("Print log messages to stderr")) + .arg(Arg::new("log").long("log").value_name("PATH").help("Write log messages to the given file")) .arg(help_arg()) .arg(Arg::new("version").short('v').long("version").action(ArgAction::SetTrue).help("Get the current version.")) .disable_help_flag(true) diff --git a/src/kata.rs b/src/kata.rs index 29b34fa..5803512 100644 --- a/src/kata.rs +++ b/src/kata.rs @@ -69,6 +69,8 @@ pub fn run( crate::logging::log("Starting containerd..."); let setup_handle = std::thread::spawn(move || -> Result<(), String> { + let just_created = ensure_containerd_dropin()?; + let active = Command::new("sudo") .args(["systemctl", "is-active", "containerd"]) .stdout(Stdio::null()) @@ -78,6 +80,8 @@ pub fn run( .unwrap_or(false); if !active { run_command_quiet("sudo", &["systemctl", "start", "containerd"])?; + } else if just_created { + run_command_quiet("sudo", &["systemctl", "restart", "containerd"])?; } if needs_bridge { @@ -360,7 +364,7 @@ fn write_resolv_conf() -> Result { fn ensure_bridge_egress_firewall(config: &RuntimeConfig, resolv_conf: Option<&Path>) -> Result<(), String> { remove_bridge_egress_firewall()?; run_command("sudo", &["modprobe", "br_netfilter"])?; - run_command("sudo", &["sysctl", "-w", "net.bridge.bridge-nf-call-iptables=1"])?; + run_command_quiet("sudo", &["sysctl", "-w", "net.bridge.bridge-nf-call-iptables=1"])?; run_command_allow_failure("sudo", &["iptables", "-N", "BUNKERBOX-EGRESS"])?; run_command("sudo", &["iptables", "-F", "BUNKERBOX-EGRESS"])?; run_command("sudo", &["iptables", "-I", "FORWARD", "1", "-s", BRIDGE_SUBNET, "-j", "BUNKERBOX-EGRESS"])?; @@ -636,6 +640,38 @@ fn run_command_quiet(program: &str, args: &[&str]) -> Result<(), String> { Ok(()) } +fn ensure_containerd_dropin() -> Result { + let dir = "/etc/systemd/system/containerd.service.d"; + let path = "/etc/systemd/system/containerd.service.d/bunkerbox.conf"; + let content = "[Service]\nStandardError=journal\n"; + + if std::fs::read_to_string(path).map(|c| c == content).unwrap_or(false) { + return Ok(false); + } + + run_command_quiet("sudo", &["mkdir", "-p", dir])?; + + let mut child = Command::new("sudo") + .args(["tee", path]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("sudo tee: {e}"))?; + + use std::io::Write; + child.stdin.as_mut().unwrap().write_all(content.as_bytes()).map_err(|e| format!("write dropin: {e}"))?; + drop(child.stdin.take()); + + let status = child.wait().map_err(|e| format!("sudo tee: {e}"))?; + if !status.success() { + return Err("failed to write containerd drop-in".to_string()); + } + + run_command_quiet("sudo", &["systemctl", "daemon-reload"])?; + Ok(true) +} + /// Prompts the user with a yes/no question and returns `true` on 'y'/'Y'. fn confirm(prompt: &str) -> bool { use std::io::{self, Read, Write}; diff --git a/src/logging.rs b/src/logging.rs index 0d22969..0c2fd77 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -2,15 +2,23 @@ use std::cell::RefCell; use std::fs::OpenOptions; use std::io::Write; use std::os::unix::io::RawFd; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; -const LOG_PATH: &str = "/tmp/bunkerbox.log"; +static VERBOSE: AtomicBool = AtomicBool::new(false); +static LOG_FILE: Mutex> = Mutex::new(None); thread_local! { static STATUS_FD: RefCell> = const { RefCell::new(None) }; } -pub fn log_path() -> &'static str { - LOG_PATH +pub fn configure(verbose: bool, log_file: Option) { + VERBOSE.store(verbose, Ordering::Relaxed); + *LOG_FILE.lock().unwrap() = log_file; +} + +pub fn log_path() -> String { + LOG_FILE.lock().unwrap().clone().unwrap_or_else(|| "/tmp/bunkerbox.log".to_string()) } pub fn set_status_fd(fd: RawFd) { @@ -57,15 +65,21 @@ pub fn log(msg: &str) { let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs(); let line = format!("[{ts}] {msg}\n"); - eprint!("[bb] {line}"); + if VERBOSE.load(Ordering::Relaxed) { + eprint!("[bb] {line}"); + } - if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(LOG_PATH) { - let _ = f.write_all(line.as_bytes()); + if let Some(ref path) = *LOG_FILE.lock().unwrap() { + if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(path) { + let _ = f.write_all(line.as_bytes()); + } } STATUS_FD.with(|f| { if let Some(fd) = *f.borrow() { - let title = format!("Log: {LOG_PATH}"); + let log_ref = LOG_FILE.lock().unwrap(); + let title = if let Some(ref path) = *log_ref { format!("Log: {path}") } else { "Bunkerbox".to_string() }; + drop(log_ref); let payload = crate::vscomm::encode_ui_payload("popup", "info", &title, msg); let mut buf = b"@".to_vec(); buf.extend_from_slice(&payload); diff --git a/src/main.rs b/src/main.rs index 929501a..a8e48d8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,5 @@ use bunkerbox::cfg::{ProjectConfig, WorkspaceMode}; -use bunkerbox::{cfg, cfgsetup, clidef, cmdrun, daemon, kata, overlay, tui, vscomm, workspace}; +use bunkerbox::{cfg, cfgsetup, clidef, cmdrun, daemon, kata, logging, overlay, tui, vscomm, workspace}; use std::cell::RefCell; use std::ffi::OsString; use std::os::unix::io::RawFd; @@ -15,6 +15,10 @@ fn main() { } fn run() -> Result<(), String> { + let verbose = flag_present("verbose"); + let log_file = option_from_args("log")?.map(|v| v.to_string_lossy().into_owned()); + logging::configure(verbose, log_file); + let workspace_override = workspace_mode_from_args()?; if cfg::RuntimeConfig::invoked_name()? != clidef::APPNAME { @@ -144,6 +148,22 @@ fn option_from_args(name: &str) -> Result, String> { Ok(None) } +fn flag_present(name: &str) -> bool { + let long = format!("--{name}"); + let prefix = format!("--{name}="); + for arg in std::env::args_os().skip(1) { + if arg == long.as_str() { + return true; + } + if let Some(s) = arg.to_str() { + if s.starts_with(&prefix) { + return true; + } + } + } + false +} + fn run_packaged_runtime(config: cfg::RuntimeConfig, workspace_override: Option, share_dir: &Path) -> Result<(), String> { if config.oci.as_os_str().is_empty() { return Err("runtime config missing oci".to_string()); diff --git a/src/tui.rs b/src/tui.rs index c8aa87f..b078de4 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -52,6 +52,9 @@ pub struct OverlayState { pub popup_title: Option, pub pending: Vec, pub has_error: bool, + pub hide_on_ascii: bool, + pub hide_on_content: Option, + pub last_content_scan: Instant, } impl Default for OverlayState { @@ -62,11 +65,28 @@ impl Default for OverlayState { impl OverlayState { pub fn new() -> Self { - Self { status_text: String::new(), popup: PopupWidget::new(), popup_title: None, pending: Vec::new(), has_error: false } + Self { + status_text: String::new(), + popup: PopupWidget::new(), + popup_title: None, + pending: Vec::new(), + has_error: false, + hide_on_ascii: false, + hide_on_content: None, + last_content_scan: Instant::now(), + } } } pub fn dispatch_ui_command(state: &mut OverlayState, widget: &str, command: &str, options: &str, value: &str) { + if widget == WIDGET_POPUP && command == CMD_HIDE && !value.is_empty() { + if value == "ASCII" { + state.hide_on_ascii = true; + } else { + state.hide_on_content = Some(value.to_string()); + } + } + let triggers = parse_triggers(options); if !triggers.is_empty() { state.pending.push(PendingAction { @@ -378,6 +398,44 @@ fn hold_error_popup(overlay: &Arc>, term: &mut Terminal bool { + if pattern.is_empty() { + return false; + } + let (rows, cols) = screen.size(); + for row in 0..rows { + for col in 0..cols { + if let Some(cell) = screen.cell(row, col) { + if cell.is_wide_continuation() { + continue; + } + let contents = cell.contents(); + if contents.contains(pattern) { + return true; + } + } + } + } + false +} + +fn screen_has_ascii_alphanumeric(screen: &vt100::Screen) -> bool { + let (rows, cols) = screen.size(); + for row in 0..rows { + for col in 0..cols { + if let Some(cell) = screen.cell(row, col) { + if cell.is_wide_continuation() { + continue; + } + if cell.contents().chars().any(|c| c.is_ascii_alphanumeric()) { + return true; + } + } + } + } + false +} + /// Renders PTY output through ratatui until the child process exits. /// /// Forwards real keystrokes to the PTY master, parses terminal output through @@ -522,6 +580,20 @@ where } } + if (now - state.last_content_scan).as_millis() >= 100 { + state.last_content_scan = now; + if state.hide_on_ascii && screen_has_ascii_alphanumeric(term.screen()) { + state.popup.hide(); + state.hide_on_ascii = false; + } + if let Some(ref pat) = state.hide_on_content.clone() { + if screen_contains(term.screen(), pat) { + state.popup.hide(); + state.hide_on_content = None; + } + } + } + let mut i = 0; while i < state.pending.len() { let fire = state.pending[i].triggers.iter().any(|t| match t { diff --git a/src/wrap.rs b/src/wrap.rs index d513d71..eaba7c3 100644 --- a/src/wrap.rs +++ b/src/wrap.rs @@ -16,7 +16,7 @@ pub fn generate_init_script(commands: &[String]) -> String { let label = truncate_label(cmd, 60); script.push_str(&format!("bunkerbox-status status set \"Running: {label}\"\n")); if i == count.saturating_sub(1) { - script.push_str("bunkerbox-status popup hide \"SEC_2\"\n"); + script.push_str("bunkerbox-status popup hide --on-find-ascii\n"); } script.push_str(&format!("{cmd}\n")); script.push_str(&format!("bunkerbox-status status set \"Done: {label}\"\n\n")); From bacc2d8b49ede45728f3b3c61e4d133b041ccd1f Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 31 Jul 2026 21:49:13 +0200 Subject: [PATCH 35/42] Do not always grab sudo password (NOPASSWD) --- src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index a8e48d8..1283310 100644 --- a/src/main.rs +++ b/src/main.rs @@ -217,7 +217,7 @@ fn run_packaged_runtime(config: cfg::RuntimeConfig, workspace_override: Option Date: Fri, 31 Jul 2026 21:54:53 +0200 Subject: [PATCH 36/42] Update versions --- Cargo.lock | 98 +++++++++++++++++++++++++++--------------------------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4ff9f23..b7ba00f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -265,18 +265,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078" dependencies = [ "anstream", "anstyle", @@ -439,7 +439,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -450,7 +450,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -484,7 +484,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -509,9 +509,9 @@ dependencies = [ [[package]] name = "either" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] name = "equivalent" @@ -556,9 +556,9 @@ checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "filedescriptor" @@ -651,7 +651,7 @@ checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -739,9 +739,9 @@ dependencies = [ [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "hashbrown" @@ -830,7 +830,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -890,9 +890,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libm" @@ -1050,7 +1050,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1125,7 +1125,7 @@ dependencies = [ "by_address", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1191,7 +1191,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1243,7 +1243,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1296,18 +1296,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -1577,9 +1577,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -1587,29 +1587,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -1731,7 +1731,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1753,9 +1753,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -1900,7 +1900,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1937,9 +1937,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "tokio" -version = "1.53.0" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -1953,13 +1953,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] @@ -2144,7 +2144,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -2349,22 +2349,22 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "zerocopy" -version = "0.8.54" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.54" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] From a9c82f02bb11385070bc086b9790e476d7322529 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 31 Jul 2026 22:12:58 +0200 Subject: [PATCH 37/42] Use glamour widget input for password --- Cargo.lock | 5 ++-- Cargo.toml | 2 +- images/kilocode.conf | 2 +- src/tui.rs | 21 ++++++++--------- src/tui/popup.rs | 54 ++++++++++++++++++++------------------------ 5 files changed, 38 insertions(+), 46 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b7ba00f..2250c68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1408,8 +1408,9 @@ dependencies = [ [[package]] name = "ratatui-glamour" -version = "0.2.0" -source = "git+https://github.com/tinythings/ratatui-glamour.git?rev=1545394#1545394cc3481bd5595f5a1f1e1eec3287ccb2a6" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839e8afaa7fa994cceed3814118e22fa56fbc62be71db1506b8d88825a039691" dependencies = [ "crossterm 0.28.1", "ratatui", diff --git a/Cargo.toml b/Cargo.toml index f87b1ca..c380730 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ tokio-vsock = "0.7" ratatui = "0.30" crossterm = "0.28" vt100 = "0.16" -ratatui-glamour = { git = "https://github.com/tinythings/ratatui-glamour.git", rev = "1545394" } +ratatui-glamour = "0.3" [dev-dependencies] tempfile = "3" diff --git a/images/kilocode.conf b/images/kilocode.conf index 8011060..4825700 100644 --- a/images/kilocode.conf +++ b/images/kilocode.conf @@ -16,7 +16,7 @@ hooks: # runs before app starts before-app: | bunkerbox-status status set 'Launching kilocode...' - bunkerbox-status popup hide --on-find-ascii + bunkerbox-status popup hide --on-find="7" # runs after app exits after-app: | diff --git a/src/tui.rs b/src/tui.rs index b078de4..9e0c828 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -632,20 +632,17 @@ where fn handle_password_key(overlay: &Arc>, status_fd: RawFd, key: KeyEvent) { let mut state = overlay.lock().unwrap(); - match key.code { - KeyCode::Enter => { - if let Some(password) = state.popup.take_password() { - let mut response = password.into_bytes(); - response.push(b'\n'); - state.popup.hide(); - unsafe { - libc::write(status_fd, response.as_ptr() as *const libc::c_void, response.len()); - } + if key.code == KeyCode::Enter { + if let Some(password) = state.popup.password_value() { + let mut response = password.into_bytes(); + response.push(b'\n'); + state.popup.hide(); + unsafe { + libc::write(status_fd, response.as_ptr() as *const libc::c_void, response.len()); } } - KeyCode::Backspace => state.popup.pop_char(), - KeyCode::Char(c) if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => state.popup.push_char(c), - _ => {} + } else { + state.popup.handle_password_key(&key); } } diff --git a/src/tui/popup.rs b/src/tui/popup.rs index 5617c2d..d98f403 100644 --- a/src/tui/popup.rs +++ b/src/tui/popup.rs @@ -6,7 +6,11 @@ use ratatui::{ text::{Line, Span}, widgets::{Block, BorderType, Borders, Clear, Gauge, Padding, Paragraph, Widget}, }; -use ratatui_glamour::{color::blend_2d, widgets::spinner}; +use ratatui_glamour::{color::blend_2d, widgets::{passwordinput, spinner}}; + +pub struct PassModel(passwordinput::Model); +unsafe impl Send for PassModel {} +unsafe impl Sync for PassModel {} use super::palette; @@ -15,7 +19,7 @@ const SPINNER_FPS_MS: u64 = 83; pub enum PopupContent { Spinner { message: String, model: spinner::Model, last_tick: Instant }, Progress { title: String, percent: f64, label: Option }, - Password { title: String, prompt: String, value: String }, + Password { title: String, prompt: String, model: PassModel }, Info { title: Option, message: String, fg: ratatui::style::Color }, } @@ -59,7 +63,10 @@ impl PopupWidget { pub fn show_password(&mut self, title: impl Into, prompt: impl Into) { self.border_color = palette::ACCENT; - self.content = PopupContent::Password { title: title.into(), prompt: prompt.into(), value: String::new() }; + let mut model = passwordinput::Model::new(); + model.set_prompt(""); + model.focus(); + self.content = PopupContent::Password { title: title.into(), prompt: prompt.into(), model: PassModel(model) }; self.visible = true; } @@ -85,27 +92,18 @@ impl PopupWidget { self.visible = false; } - pub fn push_char(&mut self, c: char) { - if let PopupContent::Password { ref mut value, .. } = &mut self.content { - value.push(c); - } - } - - pub fn pop_char(&mut self) { - if let PopupContent::Password { ref mut value, .. } = &mut self.content { - value.pop(); + pub fn handle_password_key(&mut self, key: &crossterm::event::KeyEvent) { + if let PopupContent::Password { ref mut model, .. } = &mut self.content { + model.0.handle_key(key); } } - pub fn take_password(&mut self) -> Option { - if matches!(self.content, PopupContent::Password { .. }) { - let old = - std::mem::replace(&mut self.content, PopupContent::Password { title: String::new(), prompt: String::new(), value: String::new() }); - if let PopupContent::Password { value, .. } = old { - return Some(value); - } + pub fn password_value(&self) -> Option { + if let PopupContent::Password { ref model, .. } = &self.content { + Some(model.0.value()) + } else { + None } - None } /// Compute the natural content dimensions (width, height) for the popup interior. @@ -172,8 +170,8 @@ impl PopupWidget { PopupContent::Progress { title: _, percent, label } => { self.render_progress(inner, buf, *percent, label); } - PopupContent::Password { title: _, prompt, value } => { - self.render_password(inner, buf, prompt, value); + PopupContent::Password { title: _, prompt, model } => { + self.render_password(inner, buf, prompt, model); } PopupContent::Info { message, fg, .. } => { self.render_info(inner, buf, message, *fg); @@ -268,10 +266,10 @@ impl PopupWidget { } } - fn render_password(&self, inner: Rect, buf: &mut Buffer, prompt: &str, value: &str) { + fn render_password(&self, inner: Rect, buf: &mut Buffer, prompt: &str, model: &PassModel) { let [_, prompt_area, input_area, _]: [Rect; 4] = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Min(0), Constraint::Length(1), Constraint::Length(2), Constraint::Min(0)]) + .constraints([Constraint::Min(0), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0)]) .split(inner) .as_ref() .try_into() @@ -284,12 +282,8 @@ impl PopupWidget { .alignment(Alignment::Center) .render(Rect { x: px, y: prompt_area.y, width: pw, height: 1 }, buf); - let mask = "\u{2022}".repeat(if value.is_empty() { 12 } else { value.len() }); - let mw = mask.len() as u16; - let mx = input_area.x + (input_area.width.saturating_sub(mw)) / 2; - Paragraph::new(Line::from(vec![Span::styled(mask, Style::default().fg(palette::MUTED).bg(palette::BG_3))])) - .alignment(Alignment::Center) - .render(Rect { x: mx, y: input_area.y + 1, width: mw, height: 1 }, buf); + Paragraph::new(model.0.view()) + .render(input_area, buf); } fn render_info(&self, inner: Rect, buf: &mut Buffer, message: &str, fg: ratatui::style::Color) { From 81a132b38363a28a35823af293bcb1ae2d73eaeb Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 31 Jul 2026 22:14:20 +0200 Subject: [PATCH 38/42] Lints --- src/tui/popup.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/tui/popup.rs b/src/tui/popup.rs index d98f403..9d67dad 100644 --- a/src/tui/popup.rs +++ b/src/tui/popup.rs @@ -6,7 +6,10 @@ use ratatui::{ text::{Line, Span}, widgets::{Block, BorderType, Borders, Clear, Gauge, Padding, Paragraph, Widget}, }; -use ratatui_glamour::{color::blend_2d, widgets::{passwordinput, spinner}}; +use ratatui_glamour::{ + color::blend_2d, + widgets::{passwordinput, spinner}, +}; pub struct PassModel(passwordinput::Model); unsafe impl Send for PassModel {} @@ -19,7 +22,7 @@ const SPINNER_FPS_MS: u64 = 83; pub enum PopupContent { Spinner { message: String, model: spinner::Model, last_tick: Instant }, Progress { title: String, percent: f64, label: Option }, - Password { title: String, prompt: String, model: PassModel }, + Password { title: String, prompt: String, model: Box }, Info { title: Option, message: String, fg: ratatui::style::Color }, } @@ -66,7 +69,7 @@ impl PopupWidget { let mut model = passwordinput::Model::new(); model.set_prompt(""); model.focus(); - self.content = PopupContent::Password { title: title.into(), prompt: prompt.into(), model: PassModel(model) }; + self.content = PopupContent::Password { title: title.into(), prompt: prompt.into(), model: Box::new(PassModel(model)) }; self.visible = true; } @@ -282,8 +285,7 @@ impl PopupWidget { .alignment(Alignment::Center) .render(Rect { x: px, y: prompt_area.y, width: pw, height: 1 }, buf); - Paragraph::new(model.0.view()) - .render(input_area, buf); + Paragraph::new(model.0.view()).render(input_area, buf); } fn render_info(&self, inner: Rect, buf: &mut Buffer, message: &str, fg: ratatui::style::Color) { From 18e77c324e9d085232066344e6586464fd0c0d32 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 31 Jul 2026 22:40:14 +0200 Subject: [PATCH 39/42] Hide when version string found on the screen frame --- images/kilocode.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/images/kilocode.conf b/images/kilocode.conf index 4825700..3e04001 100644 --- a/images/kilocode.conf +++ b/images/kilocode.conf @@ -16,7 +16,7 @@ hooks: # runs before app starts before-app: | bunkerbox-status status set 'Launching kilocode...' - bunkerbox-status popup hide --on-find="7" + bunkerbox-status popup hide --on-find="7.4.11" # runs after app exits after-app: | From 24e870283d9590f4bc56ea4d9da3d1f0690302c8 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 31 Jul 2026 22:40:45 +0200 Subject: [PATCH 40/42] bugfix: fire on whole string contents, not just one cell --- src/tui.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/tui.rs b/src/tui.rs index 9e0c828..c4a9e6d 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -85,6 +85,7 @@ pub fn dispatch_ui_command(state: &mut OverlayState, widget: &str, command: &str } else { state.hide_on_content = Some(value.to_string()); } + return; } let triggers = parse_triggers(options); @@ -403,20 +404,18 @@ fn screen_contains(screen: &vt100::Screen, pattern: &str) -> bool { return false; } let (rows, cols) = screen.size(); + let mut buf = String::with_capacity((rows * cols) as usize); for row in 0..rows { for col in 0..cols { if let Some(cell) = screen.cell(row, col) { if cell.is_wide_continuation() { continue; } - let contents = cell.contents(); - if contents.contains(pattern) { - return true; - } + buf.push_str(cell.contents()); } } } - false + buf.contains(pattern) } fn screen_has_ascii_alphanumeric(screen: &vt100::Screen) -> bool { From f11736a1451d97f1e4f24077fbd8e23db71136f6 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 31 Jul 2026 22:48:03 +0200 Subject: [PATCH 41/42] Update loading message --- images/kilocode.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/images/kilocode.conf b/images/kilocode.conf index 3e04001..c1d7356 100644 --- a/images/kilocode.conf +++ b/images/kilocode.conf @@ -15,7 +15,7 @@ hooks: # runs before app starts before-app: | - bunkerbox-status status set 'Launching kilocode...' + bunkerbox-status spinner show 'Launching kilocode. Please wait...' bunkerbox-status popup hide --on-find="7.4.11" # runs after app exits From 76af48ed1f58211ed8bbba0d43dd80e55722931b Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 31 Jul 2026 22:48:18 +0200 Subject: [PATCH 42/42] Bugfix on spinner view resize --- src/tui/popup.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/tui/popup.rs b/src/tui/popup.rs index 9d67dad..c1f27b4 100644 --- a/src/tui/popup.rs +++ b/src/tui/popup.rs @@ -112,8 +112,10 @@ impl PopupWidget { /// Compute the natural content dimensions (width, height) for the popup interior. fn content_size(&self) -> (u16, u16) { match &self.content { - PopupContent::Spinner { message, .. } => { - let w = (message.len() as u16 + 6).max(30); + PopupContent::Spinner { message, model, .. } => { + let frame = model.view(); + let text = format!("{} {}", frame, message); + let w = (text.len() as u16 + 8).max(30); (w, 5) } PopupContent::Progress { title, label, .. } => {