diff --git a/Cargo.lock b/Cargo.lock index 7be7eb4..c3450d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -792,7 +792,7 @@ dependencies = [ [[package]] name = "bootable-core" -version = "0.1.2" +version = "0.1.3" dependencies = [ "bzip2", "flate2", @@ -817,7 +817,7 @@ dependencies = [ [[package]] name = "bootable-desktop" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "bootable-core", @@ -831,7 +831,7 @@ dependencies = [ [[package]] name = "bootable-helper" -version = "0.1.2" +version = "0.1.3" dependencies = [ "bootable-core", "serde_json", @@ -839,7 +839,7 @@ dependencies = [ [[package]] name = "bootable-tui" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "bootable-core", diff --git a/Cargo.toml b/Cargo.toml index 1276bc1..bae8092 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ default-members = [ resolver = "2" [workspace.package] -version = "0.1.2" +version = "0.1.3" edition = "2024" license = "Apache-2.0" rust-version = "1.88" diff --git a/apps/bootable-desktop/src/main.rs b/apps/bootable-desktop/src/main.rs index 17c4202..2dfd1e7 100644 --- a/apps/bootable-desktop/src/main.rs +++ b/apps/bootable-desktop/src/main.rs @@ -9,8 +9,8 @@ use bootable_core::{ DistributionSummary, DownloadCompletion, DownloadLaunch, DownloadRequest, DownloadStatus, ImageKind, ImageReport, IsoRelease, ManagedDownloadSession, OperationState, PiCatalog, PiImage, Progress, QuickAccess, ReviewReadiness, ReviewedWriteSession, WindowsPartitionScheme, - WorkspaceStepState, WriteCompletion, WriteOptions, format_bytes, review_readiness, - target_eligibility_label, workspace_progress, + WorkspaceStepState, WriteCompletion, WriteOptions, format_bytes, removable_media_status, + review_readiness, target_eligibility_label, workspace_progress, }; use futures::{ AsyncReadExt, FutureExt, StreamExt, @@ -334,14 +334,11 @@ impl BootableView { }); let (devices, status) = match engine.discover_devices() { Ok(devices) => { - let eligible = devices - .iter() - .filter(|device| device.is_eligible_target()) - .count(); - ( - devices, - format!("{eligible} eligible target(s) detected · choose an image to begin"), - ) + let status = format!( + "{} · choose an image to begin", + removable_media_status(&devices) + ); + (devices, status) } Err(error) => (Vec::new(), error.to_string()), }; @@ -4223,7 +4220,7 @@ impl BootableView { div() .text_xs() .text_color(rgb(0x6f8299)) - .child("Auto-detecting removable media"), + .child(removable_media_status(&self.devices)), ), ) .child( diff --git a/apps/bootable-tui/src/main.rs b/apps/bootable-tui/src/main.rs index f02488d..829f023 100644 --- a/apps/bootable-tui/src/main.rs +++ b/apps/bootable-tui/src/main.rs @@ -10,8 +10,8 @@ use bootable_core::{ DistributionDetails, DistributionSummary, DownloadCompletion, DownloadLaunch, DownloadRequest, DownloadStatus, ImageReport, IsoRelease, ManagedDownloadSession, OperationState, PiCatalog, Progress, ProgressPhase, QuickAccess, ReviewReadiness, ReviewedWriteSession, - WorkspaceStepState, WriteCompletion, WriteOptions, WritePlan, format_bytes, review_readiness, - target_eligibility_label, workspace_progress, + WorkspaceStepState, WriteCompletion, WriteOptions, WritePlan, format_bytes, + removable_media_status, review_readiness, target_eligibility_label, workspace_progress, }; use clap::{Args, Parser, Subcommand}; use crossterm::event::{ @@ -761,14 +761,11 @@ impl App { let devices_result = engine.discover_devices(); let (devices, status) = match devices_result { Ok(devices) => { - let eligible = devices - .iter() - .filter(|device| device.is_eligible_target()) - .count(); - ( - devices, - format!("{eligible} eligible target(s) detected · choose an image to begin"), - ) + let status = format!( + "{} · choose an image to begin", + removable_media_status(&devices) + ); + (devices, status) } Err(error) => (Vec::new(), error.to_string()), }; @@ -4570,7 +4567,10 @@ fn draw_advanced(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Rect) { fn draw_targets(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Rect) { let items = if app.devices.is_empty() { - vec![ListItem::new("No removable drives detected").style(Style::default().fg(MUTED))] + vec![ + ListItem::new("Connect a removable USB drive, then refresh") + .style(Style::default().fg(MUTED)), + ] } else { app.devices .iter() @@ -4600,8 +4600,16 @@ fn draw_targets(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Rect) { ); let target_inner = target_block.inner(area); frame.render_widget(target_block, area); - let target_rows = - Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(target_inner); + let target_rows = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + ]) + .split(target_inner); + frame.render_widget( + Paragraph::new(removable_media_status(&app.devices)).style(Style::default().fg(ACCENT)), + target_rows[0], + ); let mut state = ListState::default().with_selected(app.selected); frame.render_stateful_widget( List::new(items) @@ -4613,19 +4621,19 @@ fn draw_targets(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Rect) { .bg(Color::Rgb(21, 48, 47)) .add_modifier(Modifier::BOLD), ), - target_rows[0], + target_rows[1], &mut state, ); frame.render_widget( Paragraph::new("Confirm the physical drive · erasure starts only after review") .style(Style::default().fg(MUTED)), - target_rows[1], + target_rows[2], ); app.hit_regions.device_rows = (0..app.devices.len()) .filter_map(|index| { - let y = target_rows[0].y.saturating_add(index as u16); - (y < target_rows[0].bottom()).then_some(( - Rect::new(target_rows[0].x, y, target_rows[0].width, 1), + let y = target_rows[1].y.saturating_add(index as u16); + (y < target_rows[1].bottom()).then_some(( + Rect::new(target_rows[1].x, y, target_rows[1].width, 1), index, )) }) diff --git a/crates/bootable-core/src/lib.rs b/crates/bootable-core/src/lib.rs index 0d2db23..39c0fce 100644 --- a/crates/bootable-core/src/lib.rs +++ b/crates/bootable-core/src/lib.rs @@ -40,8 +40,8 @@ pub use model::{ MountPoint, PlanStep, PrivilegedWriteCommand, PrivilegedWriteEvent, PrivilegedWriteRequest, Progress, ProgressPhase, ReviewReadiness, WindowsExperienceOptions, WindowsPartitionScheme, WindowsPayload, WindowsRegionalOptions, WorkspaceProgress, WorkspaceStepState, WriteOptions, - WritePlan, WriteStrategy, destructive_confirmation_ready, format_bytes, review_readiness, - target_eligibility_label, workspace_progress, + WritePlan, WriteStrategy, destructive_confirmation_ready, format_bytes, removable_media_status, + review_readiness, target_eligibility_label, workspace_progress, }; pub use operation::{OperationControl, OperationState}; pub use pi_catalog::{PiCatalog, PiDevice, PiImage}; diff --git a/crates/bootable-core/src/model.rs b/crates/bootable-core/src/model.rs index f033c72..d313643 100644 --- a/crates/bootable-core/src/model.rs +++ b/crates/bootable-core/src/model.rs @@ -248,6 +248,30 @@ pub fn target_eligibility_label(device: &Device) -> &'static str { } } +/// A compact, interface-neutral summary of the removable media inventory. +/// +/// Discovery deliberately excludes fixed disks on supported platforms. Keep +/// this wording shared so the desktop and terminal interfaces expose the same +/// connection state without implying that a target was selected. +pub fn removable_media_status(devices: &[Device]) -> String { + let connected = devices.len(); + let ready = devices + .iter() + .filter(|device| device.is_eligible_target()) + .count(); + + match (connected, ready) { + (0, _) => "No removable drives connected".into(), + (1, 1) => "1 removable drive ready".into(), + (connected, ready) if connected == ready => { + format!("{connected} removable drives ready") + } + (1, 0) => "1 removable drive connected · none eligible".into(), + (connected, 0) => format!("{connected} removable drives connected · none eligible"), + (connected, ready) => format!("{connected} removable drives connected · {ready} ready"), + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum WriteStrategy { RawVerified, @@ -669,6 +693,42 @@ mod tests { ); } + #[test] + fn removable_media_status_reports_connection_and_eligibility() { + let mut ready = Device { + id: DeviceId::new("usb"), + path: PathBuf::from("/dev/test"), + vendor: None, + model: None, + serial: None, + transport: Some("usb".into()), + capacity: 1, + removable: true, + read_only: false, + system_disk: false, + mounts: Vec::new(), + }; + + assert_eq!(removable_media_status(&[]), "No removable drives connected"); + assert_eq!( + removable_media_status(std::slice::from_ref(&ready)), + "1 removable drive ready" + ); + ready.read_only = true; + assert_eq!( + removable_media_status(std::slice::from_ref(&ready)), + "1 removable drive connected · none eligible" + ); + + let mut second = ready.clone(); + second.id = DeviceId::new("usb-2"); + second.read_only = false; + assert_eq!( + removable_media_status(&[ready, second]), + "2 removable drives connected · 1 ready" + ); + } + #[test] fn progress_metrics_include_rate_eta_and_elapsed_time() { let progress = Progress { diff --git a/packaging/Packager.toml b/packaging/Packager.toml index 0eff496..13e2816 100644 --- a/packaging/Packager.toml +++ b/packaging/Packager.toml @@ -1,7 +1,7 @@ name = "bootable" product-name = "Bootable" identifier = "app.bootable.Bootable" -version = "0.1.2" +version = "0.1.3" description = "Create verified boot media from trusted images" long-description = "A safety-first boot media writer with matching desktop and terminal interfaces." homepage = "https://github.com/debpalash/bootable"