Skip to content

Commit be228b2

Browse files
authored
feat: add interactive TUI (#5)
1 parent 63baea5 commit be228b2

File tree

12 files changed

+800
-12
lines changed

12 files changed

+800
-12
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,7 @@ Cargo.lock
44
# macOS
55
.DS_Store
66
**/.DS_Store
7+
8+
# Build artifacts
9+
*.cpio.gz
10+
vmlinuz*

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ glob = "0.3"
3030
tempfile = "3"
3131
futures = "0.3"
3232
walkdir = "2"
33+
ratatui = "0.30.0"
34+
crossterm = "0.29.0"
3335

3436
[dev-dependencies]
3537
tokio-test = "0.4"

src/lib.rs

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -174,18 +174,31 @@ impl InitramfsBuilder {
174174
}
175175
}
176176

177+
let init_dest = rootfs_path.join("init");
177178
if let Some(init_src) = &self.init_script {
178-
let init_dest = rootfs_path.join("init");
179179
info!("Setting init script from {:?}", init_src);
180180
fs::copy(init_src, &init_dest)
181181
.with_context(|| format!("Failed to copy init script from {:?}", init_src))?;
182-
183-
// Make executable
184-
let mut perms = fs::metadata(&init_dest)?.permissions();
185-
perms.set_mode(0o755);
186-
fs::set_permissions(&init_dest, perms)?;
182+
} else {
183+
info!("Generating default init script");
184+
let default_init = r#"#!/bin/sh
185+
mount -t proc proc /proc 2>/dev/null
186+
mount -t sysfs sysfs /sys 2>/dev/null
187+
mount -t devtmpfs devtmpfs /dev 2>/dev/null
188+
189+
for cmd in /docker-entrypoint.sh /entrypoint.sh /usr/bin/entrypoint.sh; do
190+
[ -x "$cmd" ] && exec "$cmd"
191+
done
192+
193+
exec /bin/sh
194+
"#;
195+
fs::write(&init_dest, default_init)?;
187196
}
188197

198+
let mut perms = fs::metadata(&init_dest)?.permissions();
199+
perms.set_mode(0o755);
200+
fs::set_permissions(&init_dest, perms)?;
201+
189202
info!("Creating CPIO archive from {:?}", rootfs_path);
190203

191204
let archive = CpioArchive::from_directory(&rootfs_path)?;

src/main.rs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ struct Cli {
1818
verbose: bool,
1919
}
2020

21+
mod tui;
22+
2123
#[derive(Subcommand)]
2224
enum Commands {
2325
/// Build an initramfs from a Docker/OCI image
@@ -89,6 +91,9 @@ enum Commands {
8991
#[arg(long, default_value = "amd64")]
9092
platform_arch: String,
9193
},
94+
95+
/// Interactive mode (TUI)
96+
Interactive,
9297
}
9398

9499
fn setup_logging(verbose: bool) {
@@ -142,7 +147,6 @@ fn parse_inject(s: &str) -> Result<(PathBuf, PathBuf)> {
142147
#[tokio::main]
143148
async fn main() -> Result<()> {
144149
let cli = Cli::parse();
145-
setup_logging(cli.verbose);
146150

147151
match cli.command {
148152
Commands::Build {
@@ -157,6 +161,7 @@ async fn main() -> Result<()> {
157161
username,
158162
password_stdin,
159163
} => {
164+
setup_logging(cli.verbose);
160165
let compression: Compression = compression
161166
.parse()
162167
.map_err(|e: String| anyhow::anyhow!(e))?;
@@ -235,6 +240,7 @@ async fn main() -> Result<()> {
235240
platform_os,
236241
platform_arch,
237242
} => {
243+
setup_logging(cli.verbose);
238244
let client = RegistryClient::new(RegistryAuth::Anonymous);
239245
let reference = RegistryClient::parse_reference(&image)?;
240246
let options = initramfs_builder::PullOptions {
@@ -255,6 +261,7 @@ async fn main() -> Result<()> {
255261
platform_os,
256262
platform_arch,
257263
} => {
264+
setup_logging(cli.verbose);
258265
let client = RegistryClient::new(RegistryAuth::Anonymous);
259266
let reference = RegistryClient::parse_reference(&image)?;
260267
let options = initramfs_builder::PullOptions {
@@ -275,11 +282,11 @@ async fn main() -> Result<()> {
275282
);
276283
}
277284
println!();
278-
println!(
279-
"Total: {} layers, {}",
280-
manifest.layers.len(),
281-
format_size(manifest.total_size)
282-
);
285+
println!("{}", format_size(manifest.total_size));
286+
}
287+
288+
Commands::Interactive => {
289+
tui::run().await?;
283290
}
284291
}
285292

src/tui/app.rs

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
use crate::tui::screens::{ImageScreen, LanguageScreen};
2+
use anyhow::Result;
3+
use initramfs_builder::{BuildResult, Compression, InitramfsBuilder, RegistryAuth};
4+
use tokio::sync::mpsc::{self, error::TryRecvError};
5+
6+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7+
pub enum Screen {
8+
Language,
9+
Image,
10+
Summary,
11+
Build,
12+
}
13+
14+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15+
pub enum WizardMode {
16+
#[default]
17+
Quick,
18+
}
19+
20+
#[derive(Debug, Clone)]
21+
pub struct BuildConfig {
22+
pub image: String,
23+
pub arch: String,
24+
pub compression: Compression,
25+
pub output: String,
26+
}
27+
28+
impl Default for BuildConfig {
29+
fn default() -> Self {
30+
Self {
31+
image: String::new(),
32+
arch: "amd64".to_string(),
33+
compression: Compression::Gzip,
34+
output: "initramfs.cpio.gz".to_string(),
35+
}
36+
}
37+
}
38+
39+
pub struct App {
40+
pub screen: Screen,
41+
pub config: BuildConfig,
42+
#[allow(dead_code)]
43+
pub mode: WizardMode,
44+
pub should_quit: bool,
45+
pub language_screen: LanguageScreen,
46+
pub image_screen: ImageScreen,
47+
pub build_progress: Option<String>,
48+
pub build_error: Option<String>,
49+
pub validation_error: Option<String>,
50+
pub loading_frame: usize,
51+
pub build_success: bool,
52+
pub build_receiver: Option<mpsc::Receiver<Result<BuildResult>>>,
53+
}
54+
55+
impl App {
56+
pub fn new() -> Self {
57+
Self {
58+
screen: Screen::Language,
59+
config: BuildConfig::default(),
60+
mode: WizardMode::Quick,
61+
should_quit: false,
62+
language_screen: LanguageScreen::new(),
63+
image_screen: ImageScreen::new(),
64+
build_progress: None,
65+
build_error: None,
66+
validation_error: None,
67+
loading_frame: 0,
68+
build_success: false,
69+
build_receiver: None,
70+
}
71+
}
72+
73+
pub fn next_screen(&mut self) {
74+
self.validation_error = None;
75+
self.sync_screen_on_exit();
76+
77+
self.screen = match self.screen {
78+
Screen::Language => {
79+
self.update_image_from_language();
80+
Screen::Image
81+
}
82+
Screen::Image => Screen::Summary,
83+
Screen::Summary => Screen::Build,
84+
Screen::Build => Screen::Build,
85+
};
86+
87+
self.sync_screen_on_enter();
88+
}
89+
90+
pub fn prev_screen(&mut self) {
91+
self.validation_error = None;
92+
self.screen = match self.screen {
93+
Screen::Language => Screen::Language,
94+
Screen::Image => Screen::Language,
95+
Screen::Summary => Screen::Image,
96+
Screen::Build => Screen::Summary,
97+
};
98+
self.sync_screen_on_enter();
99+
}
100+
101+
pub fn sync_screen_on_enter(&mut self) {
102+
if let Screen::Image = self.screen {
103+
self.image_screen.sync_from_config(&self.config.image);
104+
}
105+
}
106+
107+
pub fn sync_screen_on_exit(&mut self) {
108+
if let Screen::Image = self.screen {
109+
self.config.image = self.image_screen.sync_to_config();
110+
}
111+
}
112+
113+
fn update_image_from_language(&mut self) {
114+
let preset = &self.language_screen.presets[self.language_screen.selected];
115+
if !preset.versions.is_empty() {
116+
let version_idx = self
117+
.language_screen
118+
.version_selected
119+
.min(preset.versions.len() - 1);
120+
self.config.image = preset.versions[version_idx].1.to_string();
121+
}
122+
}
123+
124+
pub fn validate_current_screen(&mut self) -> bool {
125+
self.validation_error = None;
126+
match self.screen {
127+
Screen::Image => {
128+
let image = self.image_screen.input.trim();
129+
if image.is_empty() {
130+
self.validation_error = Some("Image cannot be empty".to_string());
131+
return false;
132+
}
133+
}
134+
Screen::Summary => {
135+
if self.config.image.trim().is_empty() {
136+
self.validation_error = Some("Image is required".to_string());
137+
return false;
138+
}
139+
}
140+
_ => {}
141+
}
142+
true
143+
}
144+
145+
pub fn start_build(&mut self) {
146+
self.screen = Screen::Build;
147+
self.build_progress = Some("Building initramfs...".to_string());
148+
self.loading_frame = 0;
149+
150+
let image = self.config.image.clone();
151+
let arch = self.config.arch.clone();
152+
let compression = self.config.compression;
153+
let output = self.config.output.clone();
154+
let (tx, rx) = mpsc::channel::<Result<BuildResult>>(1);
155+
156+
self.build_receiver = Some(rx);
157+
158+
tokio::spawn(async move {
159+
let builder = InitramfsBuilder::new()
160+
.image(&image)
161+
.compression(compression)
162+
.platform("linux", &arch)
163+
.auth(RegistryAuth::Anonymous);
164+
165+
let result = builder.build(&output).await;
166+
let _ = tx.send(result).await;
167+
});
168+
}
169+
170+
pub fn check_build_status(&mut self) {
171+
if let Some(rx) = &mut self.build_receiver {
172+
let rx: &mut mpsc::Receiver<Result<BuildResult>> = rx;
173+
match rx.try_recv() {
174+
Ok(result) => {
175+
self.build_receiver = None;
176+
match result {
177+
Ok(res) => {
178+
self.build_progress = Some(format!(
179+
"Success! Output: {} ({} entries, {:.2} MB)",
180+
self.config.output,
181+
res.entries,
182+
res.compressed_size as f64 / 1_048_576.0
183+
));
184+
self.build_success = true;
185+
}
186+
Err(e) => {
187+
self.build_error = Some(format!("Build failed: {}", e));
188+
self.build_success = false;
189+
}
190+
}
191+
}
192+
Err(TryRecvError::Empty) => {}
193+
Err(TryRecvError::Disconnected) => {
194+
self.build_receiver = None;
195+
self.build_error =
196+
Some("Build task panicked or disconnected unexpectedly".to_string());
197+
self.build_success = false;
198+
}
199+
}
200+
}
201+
}
202+
203+
pub fn on_tick(&mut self) {
204+
self.loading_frame = self.loading_frame.wrapping_add(1);
205+
}
206+
207+
pub fn generate_cli_command(&self) -> String {
208+
format!(
209+
"initramfs-builder build {} \\\n --platform-arch {} \\\n -c {} \\\n -o {}",
210+
self.config.image, self.config.arch, self.config.compression, self.config.output
211+
)
212+
}
213+
}

0 commit comments

Comments
 (0)