Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
19 changes: 8 additions & 11 deletions apps/bootable-desktop/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()),
};
Expand Down Expand Up @@ -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(
Expand Down
44 changes: 26 additions & 18 deletions apps/bootable-tui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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()),
};
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -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,
))
})
Expand Down
4 changes: 2 additions & 2 deletions crates/bootable-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
60 changes: 60 additions & 0 deletions crates/bootable-core/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion packaging/Packager.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Loading