From 85615b6fc3d001244e1d951759b91ac442dcbfe0 Mon Sep 17 00:00:00 2001 From: debpalash <4178343+debpalash@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:24:32 +0530 Subject: [PATCH 01/53] feat: remembered preferences, selected-drive details, and shared Guide Add recent images and checksum/folder preferences, selected-drive details, and a Guide overlay with shortcuts to both the GUI and TUI. --- CHANGELOG.md | 10 + apps/bootable-desktop/src/main.rs | 403 +++++++++++++++++++++--- apps/bootable-tui/src/main.rs | 339 ++++++++++++++++++-- crates/bootable-core/src/guide.rs | 258 +++++++++++++++ crates/bootable-core/src/lib.rs | 4 + crates/bootable-core/src/preferences.rs | 233 ++++++++++++++ docs/ui-parity.md | 10 + 7 files changed, 1190 insertions(+), 67 deletions(-) create mode 100644 crates/bootable-core/src/guide.rs create mode 100644 crates/bootable-core/src/preferences.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c5cb6fc..489ffba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to Bootable are documented here. +## Unreleased + +- Added remembered preferences shared by the GUI and TUI: last image folder, checksum algorithm, and + the four most recent images, reusable in one click (GUI) or with keys `1`–`4` (TUI). Target + drives and destructive options are never remembered. +- Added selected-drive details in both interfaces: connection, masked serial tail, and mounted + volumes that will be unmounted before writing. +- Added a shared Guide (`?` button, F1 or `?`) listing every action and its key in each interface, + plus desktop shortcuts: Ctrl/Cmd+O image, G discover, R refresh drives, P review plan, Esc close. + ## 0.1.1 — 2026-08-27 - Added native Linux AppImage, DEB, and RPM packages, each containing the desktop app, TUI, and diff --git a/apps/bootable-desktop/src/main.rs b/apps/bootable-desktop/src/main.rs index 8fd04fb..ed41294 100644 --- a/apps/bootable-desktop/src/main.rs +++ b/apps/bootable-desktop/src/main.rs @@ -7,11 +7,11 @@ use bootable_core::{ BadBlockCheck, Bootable, CacheMode, CatalogFacet, CatalogState, ChecksumAlgorithm, Device, DiscoverySession, DiscoverySource, DistributionBundle, DistributionDetails, DistributionSummary, DownloadCompletion, DownloadLaunch, DownloadRequest, DownloadStatus, - ImageKind, ImageReport, IsoRelease, ManagedDownloadSession, OperationState, PiCatalog, PiImage, - Progress, QuickAccess, ReviewReadiness, ReviewedWriteSession, WindowsPartitionScheme, - WorkspaceStepState, WriteCompletion, WriteOptions, catalog_search_summary, - distribution_matches_query, format_bytes, removable_media_status, review_readiness, - target_eligibility_label, workspace_progress, + HELP_INTRO, HELP_SECTIONS, ImageKind, ImageReport, IsoRelease, ManagedDownloadSession, + OperationState, PiCatalog, PiImage, Preferences, Progress, QuickAccess, ReviewReadiness, + ReviewedWriteSession, WindowsPartitionScheme, WorkspaceStepState, WriteCompletion, + WriteOptions, catalog_search_summary, device_details, distribution_matches_query, format_bytes, + removable_media_status, review_readiness, target_eligibility_label, workspace_progress, }; use futures::{ AsyncReadExt, FutureExt, StreamExt, @@ -29,6 +29,19 @@ use gpui_component::{ }; use gpui_http_client::{AsyncBody, HttpClient, Url, http}; +actions!( + bootable, + [ + OpenImage, + ToggleDiscover, + RefreshDrives, + ReviewPlan, + ToggleHelp, + CloseOverlay + ] +); + +const KEY_CONTEXT: &str = "BootableWorkspace"; const DEVICE_SCAN_INTERVAL: Duration = Duration::from_secs(1); const DOWNLOAD_SCAN_INTERVAL: Duration = Duration::from_secs(5); const BRAND_MARK_SVG: &[u8] = include_bytes!("../../../assets/bootable-mark.svg"); @@ -245,6 +258,9 @@ struct BootableView { download_session: ManagedDownloadSession, downloads_open: bool, write_session: ReviewedWriteSession, + preferences: Preferences, + help_open: bool, + focus_handle: FocusHandle, status: String, } @@ -345,16 +361,19 @@ impl BootableView { }; Self::schedule_device_scan(cx); Self::schedule_download_scan(cx); + let preferences = Preferences::load(); + let focus_handle = cx.focus_handle(); + window.focus(&focus_handle); let view = Self { engine, image: None, image_loading: false, devices, selected_device: None, - browse_directory: None, + browse_directory: preferences.image_directory(), options: WriteOptions::default(), advanced: false, - checksum_algorithm: ChecksumAlgorithm::Sha256, + checksum_algorithm: preferences.checksum_algorithm, catalog_open: false, discovery_session: DiscoverySession::default(), distributions: Vec::new(), @@ -378,6 +397,9 @@ impl BootableView { download_session: ManagedDownloadSession::default(), downloads_open: false, write_session: ReviewedWriteSession::default(), + preferences, + help_open: false, + focus_handle, status, }; Self::schedule_initial_catalog_load(cx); @@ -967,7 +989,9 @@ impl BootableView { let destination = job.destination.clone(); match self.download_session.use_completed(&self.engine, id) { Ok(report) => { - self.browse_directory = destination.parent().map(std::path::PathBuf::from); + self.preferences.remember_image(&report); + self.save_preferences(); + self.browse_directory = self.preferences.image_directory(); self.image = Some(report); self.advanced = false; self.downloads_open = false; @@ -1245,42 +1269,73 @@ impl BootableView { if let Some(directory) = &self.browse_directory { dialog = dialog.set_directory(directory); } - let selected = dialog.pick_file(); - if let Some(path) = selected { - self.image_loading = true; - self.status = - "Inspecting image • compressed sources are measured after expansion…".into(); + if let Some(path) = dialog.pick_file() { + self.inspect_image_path(path, cx); + } + } + + fn inspect_image_path(&mut self, path: std::path::PathBuf, cx: &mut Context) { + if self.image_loading { + self.status = "Image inspection is already running".into(); cx.notify(); - let inspected_path = path.clone(); - let task = cx.background_executor().spawn(async move { - Bootable::native() - .inspect_image(&inspected_path) - .map_err(|error| error.to_string()) - }); - cx.spawn(async move |view, cx| { - let result = task.await; - if let Some(view) = view.upgrade() { - view.update(cx, |view, cx| { - view.image_loading = false; - match result { - Ok(report) => { - view.status = format!("Recognized {}", report.kind); - view.browse_directory = path.parent().map(std::path::PathBuf::from); - view.image = Some(report); - view.advanced = false; - } - Err(error) => { - view.image = None; - view.advanced = false; - view.status = error; - } + return; + } + self.image_loading = true; + self.status = "Inspecting image • compressed sources are measured after expansion…".into(); + cx.notify(); + let inspected_path = path.clone(); + let task = cx.background_executor().spawn(async move { + Bootable::native() + .inspect_image(&inspected_path) + .map_err(|error| error.to_string()) + }); + cx.spawn(async move |view, cx| { + let result = task.await; + if let Some(view) = view.upgrade() { + view.update(cx, |view, cx| { + view.image_loading = false; + match result { + Ok(report) => { + view.status = format!("Recognized {}", report.kind); + view.preferences.remember_image(&report); + view.save_preferences(); + view.browse_directory = view.preferences.image_directory(); + view.image = Some(report); + view.advanced = false; } - cx.notify(); - }) - .ok(); - } - }) - .detach(); + Err(error) => { + view.preferences.forget_image(&path); + view.save_preferences(); + view.image = None; + view.advanced = false; + view.status = error; + } + } + cx.notify(); + }) + .ok(); + } + }) + .detach(); + } + + fn save_preferences(&mut self) { + if let Err(error) = self.preferences.save() { + self.status = format!("Preferences were not saved: {error}"); + } + } + + fn toggle_help(&mut self, cx: &mut Context) { + self.help_open = !self.help_open; + cx.notify(); + } + + fn close_overlay(&mut self, cx: &mut Context) { + if self.help_open { + self.help_open = false; + cx.notify(); + } else if self.catalog_open && !self.write_session.confirmation_open() { + self.toggle_catalog(cx); } } @@ -1367,6 +1422,8 @@ impl BootableView { fn cycle_checksum_algorithm(&mut self, cx: &mut Context) { self.checksum_algorithm = self.checksum_algorithm.next(); self.status = format!("Checksum algorithm: {}", self.checksum_algorithm); + self.preferences.checksum_algorithm = self.checksum_algorithm; + self.save_preferences(); cx.notify(); } @@ -3460,6 +3517,82 @@ impl BootableView { .on_click(cx.listener(|this, _, _, cx| this.choose_image(cx))), ), ) + .child(self.recent_images_row(cx)) + } + + fn recent_images_row(&self, cx: &mut Context) -> impl IntoElement { + let recents = self.preferences.recent_images(); + div() + .flex() + .flex_col() + .gap_1() + .when(recents.is_empty(), |row| { + row.child( + div() + .text_xs() + .text_color(rgb(0x6f8299)) + .child("Images you use appear here for one-click reuse"), + ) + }) + .when(!recents.is_empty(), |row| { + row.child( + div() + .text_xs() + .font_weight(FontWeight::SEMIBOLD) + .text_color(rgb(0x8fa4bd)) + .child("RECENT IMAGES"), + ) + .children( + recents + .into_iter() + .take(3) + .enumerate() + .map(|(index, recent)| { + let path = recent.path.clone(); + let current = self + .image + .as_ref() + .is_some_and(|image| image.path == recent.path); + div() + .id(("recent-image", index)) + .flex() + .items_center() + .justify_between() + .gap_3() + .px_3() + .py_1() + .rounded_md() + .border_1() + .border_color(rgb(if current { 0x36d3b4 } else { 0x1f2c3c })) + .bg(rgb(0x0d151f)) + .when(!self.image_loading, |item| { + item.cursor_pointer().on_click(cx.listener( + move |this, _, _, cx| { + this.inspect_image_path(path.clone(), cx) + }, + )) + }) + .child( + div() + .flex_1() + .overflow_hidden() + .text_sm() + .text_color(rgb(0xc4d2e2)) + .child(recent.file_name()), + ) + .child( + div() + .text_xs() + .text_color(rgb(if current { 0x5bd7c0 } else { 0x8fa4bd })) + .child(if current { + "In use".to_string() + } else { + format_bytes(recent.size) + }), + ) + }), + ) + }) } fn advanced_card(&self, cx: &mut Context) -> impl IntoElement { @@ -3852,6 +3985,148 @@ impl BootableView { .into_any_element() } + fn selected_device_details(&self) -> Option { + let device = self.devices.get(self.selected_device?)?; + let rows = device_details(device) + .into_iter() + .map(|row| { + div() + .flex() + .gap_2() + .child( + div() + .w(px(84.)) + .flex_shrink_0() + .text_xs() + .text_color(rgb(0x6f8299)) + .child(row.label), + ) + .child( + div() + .flex_1() + .text_xs() + .text_color(rgb(0xc4d2e2)) + .child(row.value), + ) + }) + .collect::>(); + Some( + div() + .flex() + .flex_col() + .gap_1() + .p_3() + .rounded_lg() + .border_1() + .border_color(rgb(0x1f2c3c)) + .bg(rgb(0x0d151f)) + .child( + div() + .text_xs() + .font_weight(FontWeight::SEMIBOLD) + .text_color(rgb(0x8fa4bd)) + .child("SELECTED DRIVE"), + ) + .children(rows), + ) + } + + fn help_overlay(&self, cx: &mut Context) -> impl IntoElement { + let sections = HELP_SECTIONS + .iter() + .map(|section| { + div() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_xs() + .font_weight(FontWeight::SEMIBOLD) + .text_color(rgb(0x5bd7c0)) + .child(section.title.to_uppercase()), + ) + .children(section.entries.iter().map(|entry| { + div() + .flex() + .items_start() + .justify_between() + .gap_4() + .child( + div() + .flex() + .flex_col() + .child(div().text_sm().child(entry.action)) + .child( + div() + .text_xs() + .text_color(rgb(0x8fa4bd)) + .child(entry.detail), + ), + ) + .child( + div() + .flex_shrink_0() + .text_sm() + .font_weight(FontWeight::SEMIBOLD) + .text_color(rgb(0xe5b95f)) + .child(entry.desktop), + ) + })) + }) + .collect::>(); + div() + .id("help-backdrop") + .absolute() + .size_full() + .flex() + .items_center() + .justify_center() + .p_6() + .bg(rgba(0x000000cc)) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, cx| { + this.help_open = false; + cx.notify(); + }), + ) + .child( + div() + .id("help-panel") + .w_full() + .max_w(px(640.)) + .max_h(relative(0.92)) + .overflow_y_scrollbar() + .flex() + .flex_col() + .gap_3() + .p_5() + .rounded_xl() + .border_1() + .border_color(rgb(0x243244)) + .bg(rgb(0x111923)) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child( + div() + .flex() + .items_center() + .justify_between() + .child(div().text_xl().font_weight(FontWeight::BOLD).child("Guide")) + .child( + Button::new("close-help") + .label("Close") + .on_click(cx.listener(|this, _, _, cx| { + this.help_open = false; + cx.notify(); + })), + ), + ) + .child(div().text_sm().text_color(rgb(0xa9b8c9)).child(HELP_INTRO)) + .children(sections), + ) + } + fn download_history_card(&self, cx: &mut Context) -> impl IntoElement { let rows = self .download_session @@ -4157,6 +4432,14 @@ impl BootableView { .on_click(cx.listener(|this, _, _, cx| this.toggle_advanced(cx))), ) }) + .child( + Button::new("guide") + .compact() + .label("?") + .tooltip("Guide and shortcuts (F1)") + .when(self.help_open, |button| button.primary()) + .on_click(cx.listener(|this, _, _, cx| this.toggle_help(cx))), + ) .child( Button::new("refresh") .compact() @@ -4316,6 +4599,7 @@ impl BootableView { .overflow_y_scrollbar() .child(self.target_cards(cx)), ) + .children(self.selected_device_details()) .child(div().text_xs().text_color(rgb(0x8fa4bd)).child( "Confirm the physical drive before continuing · erasure starts only after review", )) @@ -4460,6 +4744,28 @@ impl Render for BootableView { div() .size_full() .relative() + .key_context(KEY_CONTEXT) + .track_focus(&self.focus_handle) + .on_action(cx.listener(|this, _: &OpenImage, _, cx| { + if !this.write_session.is_reviewing() { + this.choose_image(cx); + } + })) + .on_action(cx.listener(|this, _: &ToggleDiscover, _, cx| { + if !this.write_session.is_reviewing() { + this.toggle_catalog(cx); + } + })) + .on_action(cx.listener(|this, _: &RefreshDrives, _, cx| { + this.refresh_devices(cx); + })) + .on_action(cx.listener(|this, _: &ReviewPlan, _, cx| { + if !this.write_session.is_reviewing() { + this.preview_plan(cx); + } + })) + .on_action(cx.listener(|this, _: &ToggleHelp, _, cx| this.toggle_help(cx))) + .on_action(cx.listener(|this, _: &CloseOverlay, _, cx| this.close_overlay(cx))) .bg(rgb(0x0b1119)) .text_color(rgb(0xe8f0f8)) .flex() @@ -4587,6 +4893,10 @@ impl Render for BootableView { .when(self.write_session.confirmation_open(), |root| { root.child(self.write_confirmation_modal(cx)) }) + .when( + self.help_open && !self.write_session.confirmation_open(), + |root| root.child(self.help_overlay(cx)), + ) } } @@ -4681,6 +4991,15 @@ fn main() { cx.set_http_client(Arc::new(client)); } gpui_component::init(cx); + cx.bind_keys([ + KeyBinding::new("secondary-o", OpenImage, Some(KEY_CONTEXT)), + KeyBinding::new("secondary-g", ToggleDiscover, Some(KEY_CONTEXT)), + KeyBinding::new("secondary-r", RefreshDrives, Some(KEY_CONTEXT)), + KeyBinding::new("secondary-p", ReviewPlan, Some(KEY_CONTEXT)), + KeyBinding::new("f1", ToggleHelp, Some(KEY_CONTEXT)), + KeyBinding::new("secondary-/", ToggleHelp, Some(KEY_CONTEXT)), + KeyBinding::new("escape", CloseOverlay, Some(KEY_CONTEXT)), + ]); gpui_component::Theme::change(gpui_component::ThemeMode::Dark, None, cx); { let theme = gpui_component::Theme::global_mut(cx); diff --git a/apps/bootable-tui/src/main.rs b/apps/bootable-tui/src/main.rs index adfbcaf..f5cc788 100644 --- a/apps/bootable-tui/src/main.rs +++ b/apps/bootable-tui/src/main.rs @@ -8,11 +8,11 @@ use bootable_core::{ BadBlockCheck, Bootable, CacheMode, CatalogFacet, CatalogFetch, CatalogState, ChecksumAlgorithm, Device, DiscoverySession, DiscoverySource, DistributionBundle, DistributionDetails, DistributionSummary, DownloadCompletion, DownloadLaunch, DownloadRequest, - DownloadStatus, ImageReport, IsoRelease, ManagedDownloadSession, OperationState, PiCatalog, - Progress, ProgressPhase, QuickAccess, ReviewReadiness, ReviewedWriteSession, - WorkspaceStepState, WriteCompletion, WriteOptions, WritePlan, catalog_search_summary, - distribution_matches_query, format_bytes, removable_media_status, review_readiness, - target_eligibility_label, workspace_progress, + DownloadStatus, HELP_INTRO, HELP_SECTIONS, ImageReport, IsoRelease, ManagedDownloadSession, + OperationState, PiCatalog, Preferences, Progress, ProgressPhase, QuickAccess, ReviewReadiness, + ReviewedWriteSession, WorkspaceStepState, WriteCompletion, WriteOptions, WritePlan, + catalog_search_summary, device_details, distribution_matches_query, format_bytes, + removable_media_status, review_readiness, target_eligibility_label, workspace_progress, }; use clap::{Args, Parser, Subcommand}; use crossterm::event::{ @@ -606,12 +606,14 @@ fn render_plan_text(plan: &WritePlan) { println!("Confirmation: {}", plan.confirmation_phrase); } +type ImageInspection = std::result::Result<(ImageReport, PathBuf), (String, PathBuf)>; + struct App { engine: Bootable, devices: Vec, image: Option, image_loading: bool, - image_receiver: Option>>, + image_receiver: Option>, initial_image: Option, selected: Option, status: String, @@ -652,6 +654,8 @@ struct App { write_receiver: Option>, hit_regions: HitRegions, workspace_focus: WorkspaceFocus, + preferences: Preferences, + help_open: bool, } enum DownloadUpdate { @@ -729,6 +733,8 @@ impl WorkspaceFocus { #[derive(Default)] struct HitRegions { open_image: Option, + guide: Option, + recent_rows: Vec<(Rect, usize)>, discover: Option, choose_folder: Option, windows_options: Option, @@ -794,6 +800,7 @@ impl App { }; let initial_image = image_path; let image = None; + let preferences = Preferences::load(); let (catalog_sender, catalog_receiver) = mpsc::channel(); Self { engine, @@ -806,8 +813,8 @@ impl App { status, options: WriteOptions::default(), advanced: false, - checksum_algorithm: ChecksumAlgorithm::Sha256, - browse_directory: None, + checksum_algorithm: preferences.checksum_algorithm, + browse_directory: preferences.image_directory(), catalog_open: false, discovery_session: DiscoverySession::default(), distributions: Vec::new(), @@ -841,9 +848,34 @@ impl App { write_receiver: None, hit_regions: HitRegions::default(), workspace_focus: WorkspaceFocus::Source, + preferences, + help_open: false, + } + } + + fn save_preferences(&mut self) { + if let Err(error) = self.preferences.save() { + self.status = format!("Preferences were not saved: {error}"); + } + } + + fn remember_image(&mut self, image: &ImageReport) { + self.preferences.remember_image(image); + self.save_preferences(); + self.browse_directory = self.preferences.image_directory(); + } + + fn use_recent_image(&mut self, index: usize) { + match self.preferences.recent_images().get(index) { + Some(recent) => self.inspect_image_path(recent.path.clone()), + None => self.status = "No recent image in that position".into(), } } + fn toggle_help(&mut self) { + self.help_open = !self.help_open; + } + fn load_distrowatch(&mut self) { self.load_distrowatch_with(CacheMode::PreferCache); } @@ -1419,7 +1451,7 @@ impl App { let destination = job.destination.clone(); match self.download_session.use_completed(&self.engine, &id) { Ok(report) => { - self.browse_directory = destination.parent().map(PathBuf::from); + self.remember_image(&report); self.image = Some(report); self.advanced = false; self.downloads_open = false; @@ -1914,10 +1946,10 @@ impl App { self.status = "Inspecting image • compressed sources are measured after expansion…".into(); let (sender, receiver) = mpsc::channel(); std::thread::spawn(move || { - let result = Bootable::native() - .inspect_image(&path) - .map(|report| (report, path)) - .map_err(|error| error.to_string()); + let result = match Bootable::native().inspect_image(&path) { + Ok(report) => Ok((report, path)), + Err(error) => Err((error.to_string(), path)), + }; let _ = sender.send(result); }); self.image_receiver = Some(receiver); @@ -1928,15 +1960,17 @@ impl App { return; }; match receiver.try_recv() { - Ok(Ok((image, path))) => { + Ok(Ok((image, _))) => { self.image_loading = false; self.status = format!("Recognized {}", image.kind); - self.browse_directory = path.parent().map(PathBuf::from); + self.remember_image(&image); self.image = Some(image); self.advanced = false; } - Ok(Err(error)) => { + Ok(Err((error, path))) => { self.image_loading = false; + self.preferences.forget_image(&path); + self.save_preferences(); self.image = None; self.advanced = false; self.status = error; @@ -2334,6 +2368,8 @@ impl App { fn cycle_checksum_algorithm(&mut self) { self.checksum_algorithm = self.checksum_algorithm.next(); self.status = format!("Checksum algorithm: {}", self.checksum_algorithm); + self.preferences.checksum_algorithm = self.checksum_algorithm; + self.save_preferences(); } fn cycle_bad_blocks(&mut self) { @@ -2400,6 +2436,14 @@ impl App { self.cancel_download(); } else if contains(self.hit_regions.open_image, point) { self.choose_image(); + } else if let Some(index) = self + .hit_regions + .recent_rows + .iter() + .find(|(area, _)| area.contains(point.into())) + .map(|(_, index)| *index) + { + self.use_recent_image(index); } else if contains(self.hit_regions.advanced, point) { self.toggle_advanced(); } else if contains(self.hit_regions.choose_folder, point) { @@ -2463,6 +2507,12 @@ impl App { } fn handle_mouse(&mut self, mouse: MouseEvent) -> bool { + if self.help_open { + if let MouseEventKind::Down(MouseButton::Left) = mouse.kind { + self.help_open = false; + } + return false; + } if self.write_session.confirmation_open() { if let MouseEventKind::Down(MouseButton::Left) = mouse.kind { let point = (mouse.column, mouse.row); @@ -2500,6 +2550,10 @@ impl App { } if let MouseEventKind::Down(MouseButton::Left) = mouse.kind { let point = (mouse.column, mouse.row); + if contains(self.hit_regions.guide, point) { + self.toggle_help(); + return false; + } if contains(self.hit_regions.downloads, point) { self.toggle_downloads(); return false; @@ -2730,6 +2784,22 @@ fn event_loop(terminal: &mut Terminal>, app: &mut A if event::poll(Duration::from_millis(250))? { match event::read()? { Event::Key(key) if key.kind == KeyEventKind::Press => { + if app.help_open { + if matches!( + key.code, + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('?' | 'q') + ) { + app.help_open = false; + } + continue; + } + if key.code == KeyCode::Char('?') + && !app.write_session.confirmation_open() + && !app.catalog_searching + { + app.toggle_help(); + continue; + } if app.download_session.is_active() { match key.code { KeyCode::Char('p') => { @@ -2836,8 +2906,8 @@ fn event_loop(terminal: &mut Terminal>, app: &mut A KeyCode::Char('p') => app.preview(), KeyCode::Char('h') => app.checksum(), KeyCode::Char('u') => app.backup(), - KeyCode::Char('?') => { - app.status = "Keyboard: Tab / Shift+Tab moves focus · Enter activates · arrows choose a target · o image · g discover · a setup · p review · r refresh · q quit".into(); + KeyCode::Char(digit @ '1'..='4') => { + app.use_recent_image(usize::from(digit as u8 - b'1')); } KeyCode::Up | KeyCode::Char('k') => { app.move_target_selection(-1); @@ -2865,6 +2935,56 @@ fn event_loop(terminal: &mut Terminal>, app: &mut A } fn draw(frame: &mut ratatui::Frame<'_>, app: &mut App) { + draw_screen(frame, app); + if app.help_open && !app.write_session.confirmation_open() { + draw_help(frame, frame.area()); + } +} + +fn draw_help(frame: &mut ratatui::Frame<'_>, area: Rect) { + let mut lines = vec![ + Line::from(Span::styled(HELP_INTRO, Style::default().fg(Color::White))), + Line::raw(""), + ]; + for section in HELP_SECTIONS { + lines.push(Line::from(Span::styled( + section.title.to_uppercase(), + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD), + ))); + for entry in section.entries { + lines.push(Line::from(vec![ + Span::styled( + format!(" {:<12}", entry.terminal), + Style::default().fg(Color::Rgb(229, 185, 95)), + ), + Span::styled(entry.action, Style::default().fg(Color::White)), + Span::styled(format!(" · {}", entry.detail), Style::default().fg(MUTED)), + ])); + } + } + lines.push(Line::raw("")); + lines.push(Line::from(Span::styled( + "Press Esc, ? or click to close", + Style::default().fg(MUTED), + ))); + let width = area.width.saturating_sub(4).min(110); + let height = (lines.len() as u16 + 2).min(area.height.saturating_sub(2)); + let modal = Rect::new( + area.x + area.width.saturating_sub(width) / 2, + area.y + area.height.saturating_sub(height) / 2, + width, + height, + ); + frame.render_widget(Clear, modal); + frame.render_widget( + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .block(panel_block(" Guide ").style(Style::default().bg(PANEL))), + modal, + ); +} + +fn draw_screen(frame: &mut ratatui::Frame<'_>, app: &mut App) { app.hit_regions = HitRegions::default(); frame.render_widget( Block::default().style(Style::default().bg(BG)), @@ -3393,10 +3513,15 @@ fn draw_header(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Rect) { }; let columns = Layout::horizontal([ Constraint::Min(if wide { 24 } else { 12 }), - Constraint::Length(action_width), + Constraint::Length(action_width + if wide { 6 } else { 0 }), ]) .spacing(1) .split(area); + let action_columns = Layout::horizontal([Constraint::Min(0), Constraint::Length(5)]) + .spacing(1) + .split(columns[1]); + render_button(frame, action_columns[1], "?", app.help_open); + app.hit_regions.guide = Some(action_columns[1]); frame.render_widget( Paragraph::new(brand_lockup( wide, @@ -3411,7 +3536,7 @@ fn draw_header(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Rect) { action_count ]) .spacing(1) - .split(columns[1]); + .split(action_columns[0]); render_button( frame, actions[0], @@ -3548,9 +3673,18 @@ fn draw_source(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Rect) { ); let source_inner = source_block.inner(area); frame.render_widget(source_block, area); + let recents = app.preferences.recent_images(); + let recent_lines = if source_inner.height >= 7 { + (recents.len().max(1) + 1).min(usize::from(source_inner.height).saturating_sub(4)) + } else { + 0 + } as u16; + let source_rows = Layout::vertical([Constraint::Min(3), Constraint::Length(recent_lines)]) + .split(source_inner); + draw_recent_images(frame, app, &recents, source_rows[1]); let source_columns = Layout::horizontal([Constraint::Min(16), Constraint::Length(14)]) .spacing(1) - .split(source_inner); + .split(source_rows[0]); frame.render_widget( Paragraph::new(source) .style(Style::default().fg(Color::White)) @@ -3576,6 +3710,65 @@ fn draw_source(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Rect) { } } +fn draw_recent_images( + frame: &mut ratatui::Frame<'_>, + app: &mut App, + recents: &[bootable_core::RecentImage], + area: Rect, +) { + app.hit_regions.recent_rows.clear(); + if area.height == 0 { + return; + } + if recents.is_empty() { + frame.render_widget( + Paragraph::new("Images you use appear here for one-click reuse") + .style(Style::default().fg(MUTED)), + Rect::new(area.x, area.y, area.width, 1), + ); + return; + } + frame.render_widget( + Paragraph::new("RECENT IMAGES · press 1-4").style(Style::default().fg(MUTED)), + Rect::new(area.x, area.y, area.width, 1), + ); + for (index, recent) in recents + .iter() + .enumerate() + .take(usize::from(area.height) - 1) + { + let row = Rect::new(area.x, area.y + 1 + index as u16, area.width, 1); + let current = app + .image + .as_ref() + .is_some_and(|image| image.path == recent.path); + let size = if current { + "In use".to_string() + } else { + format_bytes(recent.size) + }; + let name_width = usize::from(row.width).saturating_sub(size.chars().count() + 5); + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(format!("{} ", index + 1), Style::default().fg(ACCENT)), + Span::styled( + format!( + "{:, app: &mut App, area: Rect) { let rows = Layout::vertical([ Constraint::Min(5), @@ -4698,12 +4891,36 @@ 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 detail_rows = app + .selected + .and_then(|index| app.devices.get(index)) + .map(device_details) + .filter(|_| target_inner.height >= 9) + .map(|rows| { + let join = |labels: &[&str]| { + rows.iter() + .filter(|row| labels.contains(&row.label)) + .map(|row| format!("{} {}", row.label, row.value)) + .collect::>() + .join(" · ") + }; + [join(&["Connection", "Serial"]), join(&["Mounted"])] + }); let target_rows = Layout::vertical([ Constraint::Length(1), Constraint::Min(1), + Constraint::Length(if detail_rows.is_some() { 2 } else { 0 }), Constraint::Length(1), ]) .split(target_inner); + if let Some(details) = detail_rows { + frame.render_widget( + Paragraph::new(details.join("\n")) + .style(Style::default().fg(MUTED)) + .wrap(Wrap { trim: true }), + target_rows[2], + ); + } frame.render_widget( Paragraph::new(removable_media_status(&app.devices)).style(Style::default().fg(ACCENT)), target_rows[0], @@ -4725,7 +4942,7 @@ fn draw_targets(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Rect) { frame.render_widget( Paragraph::new("Confirm the physical drive · erasure starts only after review") .style(Style::default().fg(MUTED)), - target_rows[2], + target_rows[3], ); app.hit_regions.device_rows = (0..app.devices.len()) .filter_map(|index| { @@ -4965,7 +5182,7 @@ fn advanced_height(width: u16) -> u16 { } fn workspace_height(width: u16) -> u16 { - if width >= 72 { 10 } else { 18 } + if width >= 72 { 13 } else { 24 } } fn catalog_min_height(width: u16) -> u16 { @@ -5120,8 +5337,8 @@ mod layout_tests { #[test] fn compact_cards_do_not_stretch_with_terminal_height() { - assert_eq!(workspace_height(120), 10); - assert_eq!(workspace_height(70), 18); + assert_eq!(workspace_height(120), 13); + assert_eq!(workspace_height(70), 24); assert_eq!( centered_button_area(Rect::new(10, 4, 14, 20)), Rect::new(10, 12, 14, 3) @@ -5221,3 +5438,75 @@ mod layout_tests { assert_eq!(value["data"]["total"], 100); } } + +#[cfg(test)] +mod workspace_render_tests { + use super::*; + use bootable_core::{DeviceId, MountPoint, Preferences}; + use ratatui::backend::TestBackend; + + fn render(app: &mut App, width: u16, height: u16) -> String { + let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal"); + terminal.draw(|frame| draw(frame, app)).expect("draw"); + let buffer = terminal.backend().buffer(); + (0..height) + .map(|y| { + (0..width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .join("\n") + } + + fn app_with_drive() -> App { + let mut app = App::load(Bootable::native(), None, Picker::halfblocks()); + app.preferences = Preferences::default(); + app.devices = vec![Device { + id: DeviceId::new("usb-1"), + path: PathBuf::from("/dev/sdz"), + vendor: Some("Acme".into()), + model: Some("Stick".into()), + serial: Some("ABCDEF123456".into()), + transport: Some("usb".into()), + capacity: 16 * 1024 * 1024 * 1024, + removable: true, + read_only: false, + system_disk: false, + mounts: vec![MountPoint { + device: PathBuf::from("/dev/sdz1"), + path: PathBuf::from("/run/media/u/STICK"), + }], + }]; + app.selected = Some(0); + app + } + + #[test] + fn selected_drive_details_and_empty_recents_are_visible() { + let mut app = app_with_drive(); + let screen = render(&mut app, 130, 40); + assert!(screen.contains("Connection usb"), "{screen}"); + assert!(screen.contains("…3456"), "{screen}"); + assert!(screen.contains("/run/media/u/STICK"), "{screen}"); + assert!(screen.contains("Images you use appear here"), "{screen}"); + } + + #[test] + fn unselected_drive_shows_no_details() { + let mut app = app_with_drive(); + app.selected = None; + let screen = render(&mut app, 130, 40); + assert!(!screen.contains("…3456"), "{screen}"); + } + + #[test] + fn guide_lists_every_shared_action() { + let mut app = app_with_drive(); + app.help_open = true; + let screen = render(&mut app, 130, 40); + for section in HELP_SECTIONS { + assert!(screen.contains(§ion.title.to_uppercase()), "{screen}"); + } + } +} diff --git a/crates/bootable-core/src/guide.rs b/crates/bootable-core/src/guide.rs new file mode 100644 index 0000000..022036e --- /dev/null +++ b/crates/bootable-core/src/guide.rs @@ -0,0 +1,258 @@ +use crate::model::{Device, format_bytes, target_eligibility_label}; + +/// One labelled fact about a drive, shown identically by both interfaces. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DetailRow { + pub label: &'static str, + pub value: String, +} + +const MAX_LISTED_MOUNTS: usize = 3; + +/// Facts a user needs to confirm that a drive is the physical one they mean. +pub fn device_details(device: &Device) -> Vec { + let row = |label, value: String| DetailRow { label, value }; + let mut rows = vec![ + row("Drive", device.display_name()), + row("Path", device.path.display().to_string()), + row("Capacity", format_bytes(device.capacity)), + row( + "Connection", + device + .transport + .clone() + .filter(|transport| !transport.is_empty()) + .unwrap_or_else(|| "unknown".into()), + ), + row("Serial", masked_serial(device.serial.as_deref())), + row("Status", target_eligibility_label(device).into()), + ]; + rows.push(row("Mounted", mounted_summary(device))); + rows +} + +fn masked_serial(serial: Option<&str>) -> String { + let serial = serial.map(str::trim).filter(|serial| !serial.is_empty()); + match serial { + None => "not reported".into(), + Some(serial) => { + let tail = serial.chars().rev().take(4).collect::>(); + let tail = tail.into_iter().rev().collect::(); + if serial.chars().count() > 4 { + format!("…{tail}") + } else { + tail + } + } + } +} + +fn mounted_summary(device: &Device) -> String { + if device.mounts.is_empty() { + return "nothing mounted".into(); + } + let listed = device + .mounts + .iter() + .take(MAX_LISTED_MOUNTS) + .map(|mount| mount.path.display().to_string()) + .collect::>() + .join(", "); + let hidden = device.mounts.len().saturating_sub(MAX_LISTED_MOUNTS); + if hidden > 0 { + format!("{listed} +{hidden} more · unmounted before writing") + } else { + format!("{listed} · unmounted before writing") + } +} + +/// A user-facing action shared by both interfaces, with each interface's input. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HelpEntry { + pub action: &'static str, + pub detail: &'static str, + pub desktop: &'static str, + pub terminal: &'static str, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HelpSection { + pub title: &'static str, + pub entries: &'static [HelpEntry], +} + +const fn entry( + action: &'static str, + detail: &'static str, + desktop: &'static str, + terminal: &'static str, +) -> HelpEntry { + HelpEntry { + action, + detail, + desktop, + terminal, + } +} + +pub const HELP_INTRO: &str = "Source → Target → Review & write. Nothing is written until you review the plan and acknowledge the erase."; + +pub const HELP_SECTIONS: &[HelpSection] = &[ + HelpSection { + title: "Source", + entries: &[ + entry( + "Choose an image", + "Browse for an ISO, IMG, RAW, or compressed image", + "Ctrl+O", + "o", + ), + entry( + "Use a recent image", + "Re-inspect one of your last images", + "Click a recent image", + "1–4", + ), + entry( + "Discover images", + "Open or close the distribution catalog", + "Ctrl+G", + "g", + ), + entry( + "Downloads", + "Open or close the download manager", + "Downloads button", + "m", + ), + ], + }, + HelpSection { + title: "Target", + entries: &[ + entry("Refresh drives", "Rescan removable media", "Ctrl+R", "r"), + entry( + "Choose a drive", + "Selecting is always explicit; fixed disks are blocked", + "Click a drive", + "↑ ↓ · j k", + ), + ], + }, + HelpSection { + title: "Review & write", + entries: &[ + entry( + "Review plan", + "Open the plan; nothing is written yet", + "Ctrl+P", + "p", + ), + entry( + "Stop a write", + "Stops at the next safe boundary", + "Stop button", + "x", + ), + ], + }, + HelpSection { + title: "General", + entries: &[ + entry( + "Show or hide this guide", + "Reopen it any time", + "F1 · Ctrl+/", + "?", + ), + entry( + "Close a panel", + "Dismiss this guide or the catalog", + "Esc", + "Esc", + ), + ], + }, +]; + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + use crate::model::{DeviceId, MountPoint}; + + fn device() -> Device { + Device { + id: DeviceId::new("usb-1"), + path: PathBuf::from("/dev/sdz"), + vendor: Some("Acme".into()), + model: Some("Stick".into()), + serial: Some("ABCDEF123456".into()), + transport: Some("usb".into()), + capacity: 16 * 1024 * 1024 * 1024, + removable: true, + read_only: false, + system_disk: false, + mounts: Vec::new(), + } + } + + fn value<'a>(rows: &'a [DetailRow], label: &str) -> &'a str { + &rows + .iter() + .find(|row| row.label == label) + .expect("row") + .value + } + + #[test] + fn serial_is_masked_to_its_tail() { + let rows = device_details(&device()); + assert_eq!(value(&rows, "Serial"), "…3456"); + let mut short = device(); + short.serial = Some("12".into()); + assert_eq!(value(&device_details(&short), "Serial"), "12"); + short.serial = None; + assert_eq!(value(&device_details(&short), "Serial"), "not reported"); + } + + #[test] + fn mounts_are_summarized_and_capped() { + let mut mounted = device(); + assert_eq!( + value(&device_details(&mounted), "Mounted"), + "nothing mounted" + ); + mounted.mounts = (0..5) + .map(|index| MountPoint { + device: PathBuf::from(format!("/dev/sdz{index}")), + path: PathBuf::from(format!("/run/media/u/v{index}")), + }) + .collect(); + let rows = device_details(&mounted); + let summary = value(&rows, "Mounted"); + assert!(summary.contains("/run/media/u/v0")); + assert!(!summary.contains("/run/media/u/v3")); + assert!(summary.contains("+2 more")); + } + + #[test] + fn blocked_drives_say_so() { + let mut internal = device(); + internal.removable = false; + assert_eq!( + value(&device_details(&internal), "Status"), + "Internal disk · blocked" + ); + } + + #[test] + fn every_help_entry_names_both_interfaces() { + for section in HELP_SECTIONS { + for entry in section.entries { + assert!(!entry.desktop.is_empty() && !entry.terminal.is_empty()); + } + } + } +} diff --git a/crates/bootable-core/src/lib.rs b/crates/bootable-core/src/lib.rs index 612e37e..a2d9ae1 100644 --- a/crates/bootable-core/src/lib.rs +++ b/crates/bootable-core/src/lib.rs @@ -5,12 +5,14 @@ mod discovery_session; mod download; mod download_session; mod error; +mod guide; mod inspect; mod model; mod operation; mod pi_catalog; mod plan; mod platform; +mod preferences; #[cfg(target_os = "linux")] mod privilege; #[cfg(any(target_os = "macos", all(test, unix)))] @@ -38,6 +40,7 @@ pub use download_session::{ DownloadCompletion, DownloadLaunch, DownloadRequest, ManagedDownloadSession, }; pub use error::{Error, Result}; +pub use guide::{DetailRow, HELP_INTRO, HELP_SECTIONS, HelpEntry, HelpSection, device_details}; pub use model::{ BadBlockCheck, CompressedImageKind, Device, DeviceId, ImageCompression, ImageKind, ImageReport, MountPoint, PlanStep, PrivilegedWriteCommand, PrivilegedWriteEvent, PrivilegedWriteRequest, @@ -48,6 +51,7 @@ pub use model::{ }; pub use operation::{OperationControl, OperationState}; pub use pi_catalog::{PiCatalog, PiDevice, PiImage}; +pub use preferences::{Preferences, RecentImage}; pub use privileged_protocol::serve_privileged_writer; pub use windows::{host_regional_options, suggested_account_name}; pub use write_session::{ReviewedWriteSession, WriteCompletion, WriteLaunch}; diff --git a/crates/bootable-core/src/preferences.rs b/crates/bootable-core/src/preferences.rs new file mode 100644 index 0000000..03a20c7 --- /dev/null +++ b/crates/bootable-core/src/preferences.rs @@ -0,0 +1,233 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::checksum::ChecksumAlgorithm; +use crate::error::{Error, Result, io_error}; +use crate::model::ImageReport; + +const PREFERENCES_VERSION: u32 = 1; +const MAX_RECENT_IMAGES: usize = 4; + +/// An image the user inspected before. Only the path is trusted; the image is +/// inspected again when it is reused, so nothing here can bypass the write gates. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecentImage { + pub path: PathBuf, + pub size: u64, +} + +impl RecentImage { + pub fn file_name(&self) -> String { + self.path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| self.path.display().to_string()) + } +} + +/// Convenience settings shared by the desktop and terminal interfaces. +/// +/// Destructive choices (target drive, bad-block testing, Windows setup answers) +/// are deliberately never remembered. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Preferences { + version: u32, + pub image_directory: Option, + pub checksum_algorithm: ChecksumAlgorithm, + recent_images: Vec, +} + +impl Default for Preferences { + fn default() -> Self { + Self { + version: PREFERENCES_VERSION, + image_directory: None, + checksum_algorithm: ChecksumAlgorithm::Sha256, + recent_images: Vec::new(), + } + } +} + +impl Preferences { + /// Loads the preferences, falling back to defaults when the file is absent, + /// unreadable, corrupt, or written by an incompatible version. + pub fn load() -> Self { + preferences_path() + .map(|path| Self::load_from(&path)) + .unwrap_or_default() + } + + pub fn load_from(path: &Path) -> Self { + fs::read(path) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .filter(|preferences| preferences.version == PREFERENCES_VERSION) + .unwrap_or_default() + } + + pub fn save(&self) -> Result<()> { + let path = preferences_path().ok_or_else(|| { + Error::InvalidCatalog("no per-user configuration directory is available".into()) + })?; + self.save_to(&path) + } + + pub fn save_to(&self, path: &Path) -> Result<()> { + let directory = path.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(directory).map_err(|error| io_error(directory, error))?; + let bytes = serde_json::to_vec_pretty(self) + .map_err(|error| Error::InvalidCatalog(format!("preferences: {error}")))?; + let mut file = tempfile::NamedTempFile::new_in(directory) + .map_err(|error| io_error(directory, error))?; + std::io::Write::write_all(&mut file, &bytes).map_err(|error| io_error(path, error))?; + file.persist(path) + .map_err(|error| io_error(path, error.error))?; + Ok(()) + } + + /// Records an inspected image: newest first, no duplicates, bounded length. + /// Also remembers its folder as the next browse location. + pub fn remember_image(&mut self, image: &ImageReport) { + self.recent_images + .retain(|recent| recent.path != image.path); + self.recent_images.insert( + 0, + RecentImage { + path: image.path.clone(), + size: image.size, + }, + ); + self.recent_images.truncate(MAX_RECENT_IMAGES); + if let Some(parent) = image.path.parent().filter(|parent| parent.is_dir()) { + self.image_directory = Some(parent.to_path_buf()); + } + } + + pub fn forget_image(&mut self, path: &Path) { + self.recent_images.retain(|recent| recent.path != path); + } + + /// Recent images whose files still exist, newest first. + pub fn recent_images(&self) -> Vec { + self.recent_images + .iter() + .filter(|recent| recent.path.is_file()) + .cloned() + .collect() + } + + /// The remembered folder if it still exists. + pub fn image_directory(&self) -> Option { + self.image_directory + .clone() + .filter(|directory| directory.is_dir()) + } +} + +fn preferences_path() -> Option { + configuration_root().map(|root| root.join("bootable").join("preferences.json")) +} + +fn configuration_root() -> Option { + let absolute = |name: &str| { + std::env::var_os(name) + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + }; + if cfg!(target_os = "windows") { + absolute("APPDATA") + } else if cfg!(target_os = "macos") { + absolute("HOME").map(|home| home.join("Library").join("Application Support")) + } else { + absolute("XDG_CONFIG_HOME").or_else(|| absolute("HOME").map(|home| home.join(".config"))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::ImageKind; + + fn report(path: PathBuf) -> ImageReport { + ImageReport { + path, + size: 42, + kind: ImageKind::HybridIso, + volume_label: None, + warnings: Vec::new(), + } + } + + #[test] + fn recents_are_newest_first_unique_and_bounded() { + let directory = tempfile::tempdir().expect("tempdir"); + let mut preferences = Preferences::default(); + let paths = (0..8) + .map(|index| { + let path = directory.path().join(format!("image-{index}.iso")); + fs::write(&path, b"x").expect("write"); + path + }) + .collect::>(); + for path in &paths { + preferences.remember_image(&report(path.clone())); + } + preferences.remember_image(&report(paths[3].clone())); + let recents = preferences.recent_images(); + assert_eq!(recents.len(), MAX_RECENT_IMAGES); + assert_eq!(recents[0].path, paths[3]); + assert_eq!( + recents + .iter() + .filter(|recent| recent.path == paths[3]) + .count(), + 1 + ); + assert_eq!( + preferences.image_directory(), + Some(directory.path().to_path_buf()) + ); + } + + #[test] + fn missing_files_are_hidden_and_forgotten_files_removed() { + let directory = tempfile::tempdir().expect("tempdir"); + let kept = directory.path().join("kept.iso"); + let gone = directory.path().join("gone.iso"); + fs::write(&kept, b"x").expect("write"); + fs::write(&gone, b"x").expect("write"); + let mut preferences = Preferences::default(); + preferences.remember_image(&report(kept.clone())); + preferences.remember_image(&report(gone.clone())); + fs::remove_file(&gone).expect("remove"); + assert_eq!(preferences.recent_images().len(), 1); + preferences.forget_image(&kept); + assert!(preferences.recent_images().is_empty()); + } + + #[test] + fn preferences_round_trip_and_ignore_corruption() { + let directory = tempfile::tempdir().expect("tempdir"); + let file = directory.path().join("nested").join("preferences.json"); + let image = directory.path().join("a.iso"); + fs::write(&image, b"x").expect("write"); + let mut preferences = Preferences { + checksum_algorithm: ChecksumAlgorithm::Sha512, + ..Preferences::default() + }; + preferences.remember_image(&report(image.clone())); + preferences.save_to(&file).expect("save"); + assert_eq!(Preferences::load_from(&file), preferences); + + fs::write(&file, b"{ not json").expect("corrupt"); + assert_eq!(Preferences::load_from(&file), Preferences::default()); + fs::write( + &file, + br#"{"version":99,"image_directory":null,"checksum_algorithm":"Md5","recent_images":[]}"#, + ) + .expect("future"); + assert_eq!(Preferences::load_from(&file), Preferences::default()); + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 8d6b078..b3b46a0 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -28,6 +28,16 @@ produce an explicit selection, and blocked/internal/system/read-only drives rema Both adapters show the same removable-media inventory summary before the device rows, followed by the physical path, display name, capacity, eligibility, and explicit Select/Selected state. +## Shared conveniences + +- **Preferences** (`Preferences` in `bootable-core`) store the last image folder, checksum + algorithm, and up to four recent images. Recents are re-inspected on reuse, hidden when the file + is gone, and forgotten when inspection fails. Targets and destructive options are never stored. +- **Selected-drive details** come from `device_details`: both adapters show connection, masked + serial tail, and mounted volumes only for an explicitly selected drive. +- **Guide** content comes from `HELP_SECTIONS`; each entry names the desktop and terminal input. + Adding an action to either adapter means adding it there. + ## Shared discovery states | State | Required behavior | From ae23e3df91227b26a1705c0e773e9492cde4fced Mon Sep 17 00:00:00 2001 From: debpalash <4178343+debpalash@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:30:05 +0530 Subject: [PATCH 02/53] feat(cli): flash, completions, stable exit codes, and CLI docs Add one-step 'bootable flash ' that reuses the download, plan, and confirmation paths of 'write'; add shell completions; map failures to documented exit codes (0 ok, 1 error, 2 usage, 3 confirmation/refusal, 4 verification) and report JSON errors on stderr for --json commands. Share one confirmation gate between write and flash. --- Cargo.lock | 10 + Cargo.toml | 1 + apps/bootable-tui/Cargo.toml | 1 + apps/bootable-tui/src/main.rs | 792 ++++++++++++++++++++++++++++++++-- docs/cli.md | 205 +++++++++ 5 files changed, 978 insertions(+), 31 deletions(-) create mode 100644 docs/cli.md diff --git a/Cargo.lock b/Cargo.lock index bbcd400..00ac67e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -844,6 +844,7 @@ dependencies = [ "anyhow", "bootable-core", "clap", + "clap_complete", "crossterm", "image", "ratatui", @@ -1099,6 +1100,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.6.4" diff --git a/Cargo.toml b/Cargo.toml index c045744..46f0eb1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ rust-version = "1.88" [workspace.dependencies] anyhow = "1.0" clap = { version = "4.5", features = ["derive"] } +clap_complete = "4.5" crossterm = "0.29" gpui = "0.2.2" gpui-component = "0.5.1" diff --git a/apps/bootable-tui/Cargo.toml b/apps/bootable-tui/Cargo.toml index 9e70b52..923b2ec 100644 --- a/apps/bootable-tui/Cargo.toml +++ b/apps/bootable-tui/Cargo.toml @@ -13,6 +13,7 @@ path = "src/main.rs" [dependencies] anyhow.workspace = true clap.workspace = true +clap_complete.workspace = true crossterm.workspace = true bootable-core = { path = "../../crates/bootable-core" } image.workspace = true diff --git a/apps/bootable-tui/src/main.rs b/apps/bootable-tui/src/main.rs index adfbcaf..5a323ee 100644 --- a/apps/bootable-tui/src/main.rs +++ b/apps/bootable-tui/src/main.rs @@ -1,5 +1,7 @@ +use std::fmt; use std::io::{self, IsTerminal}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; use std::sync::mpsc::{self, Receiver}; use std::time::{Duration, Instant}; @@ -14,7 +16,8 @@ use bootable_core::{ distribution_matches_query, format_bytes, removable_media_status, review_readiness, target_eligibility_label, workspace_progress, }; -use clap::{Args, Parser, Subcommand}; +use clap::{Args, CommandFactory, Parser, Subcommand}; +use clap_complete::Shell; use crossterm::event::{ self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, @@ -140,6 +143,38 @@ enum Commands { #[arg(long, default_value = "off", value_name = "off|1|2|4")] bad_block_check: BadBlockCheck, }, + /// One step: fetch (if a catalog slug), plan, then write and verify. + /// + /// The target must be named explicitly and must pass the same eligibility + /// checks as `plan` and `write`. Nothing is written unless --confirm + /// repeats the exact phrase printed by the plan. + Flash { + /// Local image path, or a catalog slug (see `bootable catalog`). + #[arg(value_name = "SLUG_OR_IMAGE")] + source: String, + /// Removable device id or path (see `bootable devices`). + target: String, + /// Release index to download when SOURCE is a catalog slug. + #[arg(long, default_value_t = 0)] + index: usize, + /// Where to save a downloaded catalog image. + #[arg(long, value_name = "ISO_FILE")] + output: Option, + #[arg(long, value_name = "EXACT_PHRASE")] + confirm: Option, + /// Emit newline-delimited JSON progress events for trusted clients. + #[arg(long)] + json_progress: bool, + #[command(flatten)] + windows: WindowsArgs, + #[arg(long, default_value = "off", value_name = "off|1|2|4")] + bad_block_check: BadBlockCheck, + }, + /// Print a shell completion script to stdout. + Completions { + #[arg(value_enum)] + shell: Shell, + }, } #[derive(Debug, Args)] @@ -168,8 +203,126 @@ struct WindowsArgs { force_windows_s_mode: bool, } -fn main() -> Result<()> { +/// Stable process exit codes. Documented in `docs/cli.md`; do not renumber. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ExitStatus { + Ok = 0, + Error = 1, + Usage = 2, + Confirmation = 3, + Verification = 4, +} + +impl ExitStatus { + fn code(self) -> u8 { + self as u8 + } + + fn name(self) -> &'static str { + match self { + Self::Ok => "ok", + Self::Error => "error", + Self::Usage => "usage", + Self::Confirmation => "confirmation_required", + Self::Verification => "verification_failed", + } + } +} + +/// CLI-level failures that carry their own exit status. +#[derive(Debug)] +enum CliError { + Usage(String), + ConfirmationRequired { phrase: String }, +} + +impl fmt::Display for CliError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Usage(message) => formatter.write_str(message), + Self::ConfirmationRequired { phrase } => write!( + formatter, + "nothing was written; repeat with --confirm '{phrase}'" + ), + } + } +} + +impl std::error::Error for CliError {} + +fn core_exit_status(error: &bootable_core::Error) -> ExitStatus { + use bootable_core::Error; + match error { + Error::ConfirmationMismatch { .. } | Error::UnsafeTarget(_) => ExitStatus::Confirmation, + Error::StalePlan(message) + | Error::PrivilegedWriteFailed(message) + | Error::InvalidDownload(message) + if is_verification_message(message) => + { + ExitStatus::Verification + } + _ => ExitStatus::Error, + } +} + +/// Core reports verification failures as message text on a few variants. +fn is_verification_message(message: &str) -> bool { + let message = message.to_ascii_lowercase(); + message.contains("verification failed") || message.contains("mismatch") +} + +fn exit_status(error: &anyhow::Error) -> ExitStatus { + if let Some(error) = error.downcast_ref::() { + return match error { + CliError::Usage(_) => ExitStatus::Usage, + CliError::ConfirmationRequired { .. } => ExitStatus::Confirmation, + }; + } + error + .downcast_ref::() + .map_or(ExitStatus::Error, core_exit_status) +} + +fn error_json(message: &str, status: ExitStatus) -> serde_json::Value { + serde_json::json!({ + "error": { "kind": status.name(), "exit_code": status.code(), "message": message } + }) +} + +/// Commands whose success output is JSON also report failures as JSON on stderr. +fn wants_json_errors(command: &Commands) -> bool { + match command { + Commands::Catalog { json, .. } + | Commands::Releases { json, .. } + | Commands::PiImages { json, .. } + | Commands::Devices { json } + | Commands::Inspect { json, .. } + | Commands::Checksum { json, .. } + | Commands::Plan { json, .. } => *json, + _ => false, + } +} + +fn main() -> ExitCode { + // clap exits with status 2 on usage errors and 0 for --help/--version. let cli = Cli::parse(); + let json_errors = cli.command.as_ref().is_some_and(wants_json_errors); + match run(cli) { + Ok(()) => ExitCode::from(ExitStatus::Ok.code()), + Err(error) => { + let status = exit_status(&error); + let message = error.to_string(); + if json_errors { + eprintln!("{}", error_json(&message, status)); + } else { + eprintln!("error: {message}"); + } + ExitCode::from(status.code()) + } + } +} + +fn run(cli: Cli) -> Result<()> { let engine = Bootable::native(); match cli.command { Some(Commands::Catalog { limit, json }) => print_catalog(&engine, limit, json), @@ -226,11 +379,43 @@ fn main() -> Result<()> { json_progress, write_options(windows, bad_block_check), ), + Some(Commands::Flash { + source, + target, + index, + output, + confirm, + json_progress, + windows, + bad_block_check, + }) => flash_image( + &engine, + FlashRequest { + source, + target, + index, + output, + confirm, + json_progress, + options: write_options(windows, bad_block_check), + }, + ), + Some(Commands::Completions { shell }) => { + print_completions(shell); + Ok(()) + } None if io::stdout().is_terminal() => run_tui(engine, cli.image), - None => bail!("interactive mode needs a terminal; use `bootable --help`"), + None => Err(CliError::Usage( + "interactive mode needs a terminal; use `bootable --help`".into(), + ) + .into()), } } +fn print_completions(shell: Shell) { + clap_complete::generate(shell, &mut Cli::command(), "bootable", &mut io::stdout()); +} + fn print_catalog(engine: &Bootable, limit: usize, json: bool) -> Result<()> { let distributions = engine.popular_distributions(limit.clamp(1, 100))?; if json { @@ -298,17 +483,9 @@ fn download_release( json_progress: bool, ) -> Result<()> { let mut reporter = ProgressReporter::new(json_progress); - let result = (|| { - let details = engine.distribution_details(slug)?; - let releases = resolve_releases(engine, &details)?; - let release = releases - .get(index) - .with_context(|| format!("release index {index} is out of range"))?; - let destination = output.unwrap_or_else(|| PathBuf::from(&release.name)); - engine - .download_iso(release, &destination, |progress| reporter.print(progress)) - .map_err(anyhow::Error::from) - })(); + let result = fetch_catalog_image(engine, slug, index, output, &mut |progress| { + reporter.print(progress) + }); match result { Ok(report) => { @@ -322,12 +499,32 @@ fn download_release( Ok(()) } Err(error) => { - reporter.failed(&error.to_string()); + reporter.failed(&error.to_string(), exit_status(&error)); Err(error) } } } +/// Resolve a catalog slug to a release, then download and verify it. +fn fetch_catalog_image( + engine: &Bootable, + slug: &str, + index: usize, + output: Option, + progress: &mut dyn FnMut(Progress), +) -> Result { + let details = engine.distribution_details(slug)?; + let releases = resolve_releases(engine, &details)?; + let release = releases.get(index).ok_or_else(|| { + CliError::Usage(format!( + "release index {index} is out of range ({} available)", + releases.len() + )) + })?; + let destination = output.unwrap_or_else(|| PathBuf::from(&release.name)); + Ok(engine.download_iso(release, &destination, progress)?) +} + fn resolve_releases(engine: &Bootable, details: &DistributionDetails) -> Result> { let mut releases = Vec::new(); let mut last_error = None; @@ -474,35 +671,200 @@ fn print_plan( Ok(()) } +/// The engine operations the write commands depend on. `Bootable` is the real +/// implementation; tests substitute a fake so no device is ever touched. +trait WriteBackend { + fn check_target(&self, target: &str) -> Result<()>; + fn fetch( + &self, + slug: &str, + index: usize, + output: Option, + progress: &mut dyn FnMut(Progress), + ) -> Result; + fn prepare(&self, image: PathBuf, target: &str, options: WriteOptions) -> Result; + fn write( + &self, + plan: &WritePlan, + confirmation: &str, + progress: &mut dyn FnMut(Progress), + ) -> Result<()>; +} + +impl WriteBackend for Bootable { + fn check_target(&self, target: &str) -> Result<()> { + let device = self + .discover_devices()? + .into_iter() + .find(|device| device.id.as_str() == target || device.path.to_string_lossy() == target) + .ok_or_else(|| bootable_core::Error::DeviceNotFound(target.into()))?; + if !device.is_eligible_target() { + return Err(bootable_core::Error::UnsafeTarget(format!( + "{}: {}", + device.path.display(), + target_eligibility_label(&device) + )) + .into()); + } + Ok(()) + } + + fn fetch( + &self, + slug: &str, + index: usize, + output: Option, + progress: &mut dyn FnMut(Progress), + ) -> Result { + fetch_catalog_image(self, slug, index, output, progress) + } + + fn prepare(&self, image: PathBuf, target: &str, options: WriteOptions) -> Result { + Ok(self.prepare_with_options(image, target, options)?) + } + + fn write( + &self, + plan: &WritePlan, + confirmation: &str, + progress: &mut dyn FnMut(Progress), + ) -> Result<()> { + Ok(self.write_with_privilege(plan, confirmation, progress)?) + } +} + fn write_image( - engine: &Bootable, + backend: &impl WriteBackend, image: PathBuf, target: &str, confirmation: Option, json_progress: bool, options: WriteOptions, ) -> Result<()> { - let plan = engine.prepare_with_options(image, target, options)?; + let plan = backend.prepare(image, target, options)?; + confirmed_write(backend, &plan, confirmation, json_progress) +} + +/// The single confirmation gate shared by `write` and `flash`: nothing is +/// written unless the caller repeats the plan's exact phrase. +fn confirmed_write( + backend: &impl WriteBackend, + plan: &WritePlan, + confirmation: Option, + json_progress: bool, +) -> Result<()> { + let mut reporter = ProgressReporter::new(json_progress); let Some(confirmation) = confirmation else { - render_plan_text(&plan); - bail!( - "nothing was written; repeat with --confirm '{}'", - plan.confirmation_phrase - ); + if json_progress { + println!( + "{}", + serde_json::json!({ + "event": "confirmation_required", + "data": { + "confirmation_phrase": plan.confirmation_phrase, + "plan": plan, + }, + }) + ); + } else { + render_plan_text(plan); + } + return Err(CliError::ConfirmationRequired { + phrase: plan.confirmation_phrase.clone(), + } + .into()); }; - let mut reporter = ProgressReporter::new(json_progress); - let result = - engine.write_with_privilege(&plan, &confirmation, |progress| reporter.print(progress)); - match result { + if !plan.confirmation_matches(&confirmation) { + let error: anyhow::Error = bootable_core::Error::ConfirmationMismatch { + expected: plan.confirmation_phrase.clone(), + } + .into(); + reporter.failed(&error.to_string(), exit_status(&error)); + return Err(error); + } + match backend.write(plan, &confirmation, &mut |progress| { + reporter.print(progress) + }) { Ok(()) => { reporter.finished(); Ok(()) } Err(error) => { - reporter.failed(&error.to_string()); - Err(error.into()) + reporter.failed(&error.to_string(), exit_status(&error)); + Err(error) + } + } +} + +struct FlashRequest { + source: String, + target: String, + index: usize, + output: Option, + confirm: Option, + json_progress: bool, + options: WriteOptions, +} + +#[derive(Debug, PartialEq, Eq)] +enum FlashSource { + Image(PathBuf), + Catalog(String), +} + +/// An existing file, or anything that looks like a path, is a local image; +/// a bare word is a catalog slug. +fn classify_source(source: &str, exists: bool) -> FlashSource { + let path_like = source.contains(['/', '\\', '.']) || source.starts_with('~'); + if exists || path_like { + FlashSource::Image(PathBuf::from(source)) + } else { + FlashSource::Catalog(source.to_owned()) + } +} + +fn flash_image(backend: &impl WriteBackend, request: FlashRequest) -> Result<()> { + let FlashRequest { + source, + target, + index, + output, + confirm, + json_progress, + options, + } = request; + let mut reporter = ProgressReporter::new(json_progress); + let image = match classify_source(&source, Path::new(&source).exists()) { + FlashSource::Image(path) => path, + FlashSource::Catalog(slug) => { + // Refuse a missing or ineligible target before a large download. + backend.check_target(&target)?; + let report = backend + .fetch(&slug, index, output, &mut |progress| { + reporter.print(progress) + }) + .inspect_err(|error| reporter.failed(&error.to_string(), exit_status(error)))?; + if confirm.is_none() && !json_progress { + eprintln!( + "Image kept at {}; pass that path instead of the slug to skip the download.", + report.path.display() + ); + } + report.path } + }; + let plan = backend + .prepare(image, &target, options) + .inspect_err(|error| reporter.failed(&error.to_string(), exit_status(error)))?; + confirmed_write(backend, &plan, confirm, json_progress)?; + if !json_progress { + println!( + "Done: {} written and verified on {}", + plan.image.path.display(), + plan.target.path.display() + ); } + Ok(()) } fn write_options(windows: WindowsArgs, bad_block_check: BadBlockCheck) -> WriteOptions { @@ -573,11 +935,18 @@ impl ProgressReporter { } } - fn failed(&self, message: &str) { + fn failed(&self, message: &str, status: ExitStatus) { if self.json { println!( "{}", - serde_json::json!({ "event": "failed", "data": { "message": message } }) + serde_json::json!({ + "event": "failed", + "data": { + "message": message, + "kind": status.name(), + "exit_code": status.code(), + }, + }) ); } } @@ -5221,3 +5590,364 @@ mod layout_tests { assert_eq!(value["data"]["total"], 100); } } + +#[cfg(test)] +mod cli_tests { + use std::cell::RefCell; + use std::path::PathBuf; + + use anyhow::Result; + use bootable_core::{ + Device, DeviceId, Error, ImageKind, ImageReport, PlanStep, Progress, WriteOptions, + WritePlan, WriteStrategy, + }; + use clap::{CommandFactory, Parser}; + use clap_complete::Shell; + + use super::{ + Cli, CliError, Commands, ExitStatus, FlashRequest, FlashSource, WriteBackend, + classify_source, error_json, exit_status, flash_image, wants_json_errors, write_image, + }; + + const PHRASE: &str = "ERASE /dev/fake TEST"; + + fn parse(arguments: &[&str]) -> Cli { + Cli::try_parse_from(arguments).expect("valid invocation") + } + + fn fake_plan(image: PathBuf, target: &str) -> WritePlan { + WritePlan { + image: ImageReport { + path: image, + size: 1024, + kind: ImageKind::HybridIso, + volume_label: None, + warnings: Vec::new(), + }, + target: Device { + id: DeviceId::new(target), + path: PathBuf::from(target), + vendor: None, + model: None, + serial: None, + transport: None, + capacity: 1 << 30, + removable: true, + read_only: false, + system_disk: false, + mounts: Vec::new(), + }, + strategy: WriteStrategy::RawVerified, + options: WriteOptions::default(), + steps: vec![PlanStep { + title: "Write".into(), + destructive: true, + }], + required_tools: Vec::new(), + confirmation_phrase: PHRASE.into(), + } + } + + /// Records every call; never touches a real device or network. + #[derive(Default)] + struct FakeBackend { + eligible: bool, + write_error: Option Error>, + fetches: RefCell>, + prepares: RefCell>, + writes: RefCell>, + } + + impl FakeBackend { + fn eligible() -> Self { + Self { + eligible: true, + ..Self::default() + } + } + } + + impl WriteBackend for FakeBackend { + fn check_target(&self, target: &str) -> Result<()> { + if self.eligible { + Ok(()) + } else { + Err(Error::UnsafeTarget(format!("{target}: Internal disk · blocked")).into()) + } + } + + fn fetch( + &self, + slug: &str, + _index: usize, + _output: Option, + _progress: &mut dyn FnMut(Progress), + ) -> Result { + self.fetches.borrow_mut().push(slug.into()); + Ok(fake_plan(PathBuf::from(format!("{slug}.iso")), "/dev/fake").image) + } + + fn prepare( + &self, + image: PathBuf, + target: &str, + _options: WriteOptions, + ) -> Result { + self.prepares + .borrow_mut() + .push((image.clone(), target.into())); + Ok(fake_plan(image, target)) + } + + fn write( + &self, + _plan: &WritePlan, + confirmation: &str, + _progress: &mut dyn FnMut(Progress), + ) -> Result<()> { + self.writes.borrow_mut().push(confirmation.into()); + match self.write_error { + Some(error) => Err(error().into()), + None => Ok(()), + } + } + } + + fn request(source: &str, confirm: Option<&str>) -> FlashRequest { + FlashRequest { + source: source.into(), + target: "/dev/fake".into(), + index: 0, + output: None, + confirm: confirm.map(Into::into), + json_progress: true, + options: WriteOptions::default(), + } + } + + #[test] + fn exit_codes_are_stable() { + assert_eq!(ExitStatus::Ok.code(), 0); + assert_eq!(ExitStatus::Error.code(), 1); + assert_eq!(ExitStatus::Usage.code(), 2); + assert_eq!(ExitStatus::Confirmation.code(), 3); + assert_eq!(ExitStatus::Verification.code(), 4); + } + + #[test] + fn errors_map_to_documented_exit_codes() { + let status = |error: anyhow::Error| exit_status(&error); + assert_eq!( + status(CliError::Usage("bad".into()).into()), + ExitStatus::Usage + ); + assert_eq!( + status(CliError::ConfirmationRequired { phrase: "x".into() }.into()), + ExitStatus::Confirmation + ); + assert_eq!( + status( + Error::ConfirmationMismatch { + expected: "x".into() + } + .into() + ), + ExitStatus::Confirmation + ); + assert_eq!( + status(Error::UnsafeTarget("system disk".into()).into()), + ExitStatus::Confirmation + ); + assert_eq!( + status(Error::StalePlan("verification failed: SHA-256 digests differ".into()).into()), + ExitStatus::Verification + ); + assert_eq!( + status(Error::InvalidDownload("SHA-256 checksum mismatch for x.iso".into()).into()), + ExitStatus::Verification + ); + assert_eq!( + status(Error::StalePlan("the device changed".into()).into()), + ExitStatus::Error + ); + assert_eq!(status(Error::NotPrivileged.into()), ExitStatus::Error); + assert_eq!(status(anyhow::anyhow!("boom")), ExitStatus::Error); + // Context added by callers must not hide the underlying status. + let wrapped = anyhow::Error::from(Error::UnsafeTarget("x".into())).context("planning"); + assert_eq!(exit_status(&wrapped), ExitStatus::Confirmation); + } + + #[test] + fn json_errors_share_one_shape() { + let value = error_json("nope", ExitStatus::Verification); + assert_eq!(value["error"]["kind"], "verification_failed"); + assert_eq!(value["error"]["exit_code"], 4); + assert_eq!(value["error"]["message"], "nope"); + let with_json = parse(&["bootable", "devices", "--json"]); + assert!(wants_json_errors(&with_json.command.expect("command"))); + let without_json = parse(&["bootable", "devices"]); + assert!(!wants_json_errors(&without_json.command.expect("command"))); + } + + #[test] + fn flash_parses_with_explicit_target_and_options() { + let cli = parse(&[ + "bootable", + "flash", + "cachyos", + "/dev/sdx", + "--index", + "2", + "--output", + "a.iso", + "--confirm", + PHRASE, + "--json-progress", + ]); + let Some(Commands::Flash { + source, + target, + index, + output, + confirm, + json_progress, + .. + }) = cli.command + else { + panic!("expected flash"); + }; + assert_eq!(source, "cachyos"); + assert_eq!(target, "/dev/sdx"); + assert_eq!(index, 2); + assert_eq!(output, Some(PathBuf::from("a.iso"))); + assert_eq!(confirm.as_deref(), Some(PHRASE)); + assert!(json_progress); + } + + #[test] + fn flash_never_infers_a_target() { + assert!(Cli::try_parse_from(["bootable", "flash", "cachyos"]).is_err()); + assert!(Cli::try_parse_from(["bootable", "flash"]).is_err()); + } + + #[test] + fn completions_parse_every_supported_shell() { + for name in ["bash", "zsh", "fish", "powershell", "elvish"] { + let cli = parse(&["bootable", "completions", name]); + assert!(matches!(cli.command, Some(Commands::Completions { .. }))); + } + assert!(Cli::try_parse_from(["bootable", "completions", "tcsh"]).is_err()); + } + + #[test] + fn completions_scripts_mention_flash() { + for shell in [ + Shell::Bash, + Shell::Zsh, + Shell::Fish, + Shell::PowerShell, + Shell::Elvish, + ] { + let mut output = Vec::new(); + clap_complete::generate(shell, &mut Cli::command(), "bootable", &mut output); + let script = String::from_utf8(output).expect("utf-8 script"); + assert!(script.contains("flash"), "{shell} completions lack flash"); + } + } + + #[test] + fn sources_are_classified_as_paths_or_slugs() { + assert_eq!( + classify_source("cachyos", false), + FlashSource::Catalog("cachyos".into()) + ); + for path in ["image.iso", "./image", "/tmp/x", "dir\\x", "~/x"] { + assert_eq!( + classify_source(path, false), + FlashSource::Image(PathBuf::from(path)) + ); + } + assert_eq!( + classify_source("local", true), + FlashSource::Image(PathBuf::from("local")) + ); + } + + #[test] + fn flash_without_confirm_plans_but_never_writes() { + let backend = FakeBackend::eligible(); + let error = flash_image(&backend, request("missing.iso", None)).expect_err("refused"); + assert_eq!(exit_status(&error), ExitStatus::Confirmation); + assert!(error.to_string().contains(PHRASE)); + assert_eq!(backend.prepares.borrow().len(), 1); + assert!(backend.writes.borrow().is_empty()); + } + + #[test] + fn flash_with_wrong_phrase_never_writes() { + let backend = FakeBackend::eligible(); + let error = + flash_image(&backend, request("missing.iso", Some("yes"))).expect_err("refused"); + assert_eq!(exit_status(&error), ExitStatus::Confirmation); + assert!(backend.writes.borrow().is_empty()); + } + + #[test] + fn flash_writes_only_with_the_exact_phrase() { + let backend = FakeBackend::eligible(); + flash_image(&backend, request("missing.iso", Some(PHRASE))).expect("flashed"); + assert_eq!(*backend.writes.borrow(), vec![PHRASE.to_owned()]); + assert_eq!( + backend.prepares.borrow()[0], + (PathBuf::from("missing.iso"), "/dev/fake".to_owned()) + ); + assert!(backend.fetches.borrow().is_empty()); + } + + #[test] + fn flash_slug_fetches_then_plans_the_downloaded_image() { + let backend = FakeBackend::eligible(); + flash_image(&backend, request("cachyos", Some(PHRASE))).expect("flashed"); + assert_eq!(*backend.fetches.borrow(), vec!["cachyos".to_owned()]); + assert_eq!(backend.prepares.borrow()[0].0, PathBuf::from("cachyos.iso")); + assert_eq!(backend.writes.borrow().len(), 1); + } + + #[test] + fn flash_refuses_ineligible_target_before_downloading() { + let backend = FakeBackend::default(); + let error = flash_image(&backend, request("cachyos", Some(PHRASE))).expect_err("refused"); + assert_eq!(exit_status(&error), ExitStatus::Confirmation); + assert!(backend.fetches.borrow().is_empty()); + assert!(backend.prepares.borrow().is_empty()); + assert!(backend.writes.borrow().is_empty()); + } + + #[test] + fn flash_reports_verification_failure_with_its_own_exit_code() { + let backend = FakeBackend { + eligible: true, + write_error: Some(|| Error::StalePlan("verification failed: digests differ".into())), + ..FakeBackend::default() + }; + let error = + flash_image(&backend, request("missing.iso", Some(PHRASE))).expect_err("failed"); + assert_eq!(exit_status(&error), ExitStatus::Verification); + } + + #[test] + fn write_shares_the_confirmation_gate() { + let backend = FakeBackend::eligible(); + let error = write_image( + &backend, + PathBuf::from("image.iso"), + "/dev/fake", + None, + true, + WriteOptions::default(), + ) + .expect_err("refused"); + assert_eq!(exit_status(&error), ExitStatus::Confirmation); + assert!(backend.writes.borrow().is_empty()); + } +} diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..047b9b3 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,205 @@ +# Command-line reference + +`bootable` with no subcommand opens the terminal UI (it needs a terminal on stdout). Every +subcommand below runs headless and is safe to script. Run `bootable --help` for the +full option list. + +## Exit codes + +| Code | Name | Meaning | +| ---- | ----------------------- | ---------------------------------------------------------------------------------------- | +| 0 | ok | The command completed. | +| 1 | error | Any other failure: I/O, network, missing device, missing privileges, stale plan. | +| 2 | usage | Bad arguments (reported by the parser), out-of-range release index, no terminal for TUI. | +| 3 | confirmation_required | Nothing was written: `--confirm` missing, wrong phrase, or the target was refused. | +| 4 | verification_failed | A checksum or post-write byte verification did not match. Treat the media as unusable. | + +Exit 3 covers every safety refusal: a missing `--confirm`, a phrase that does not match the plan, +and a target that is a system disk, an internal disk, or read-only. Exit 4 covers the +publisher-checksum check on downloads and the SHA-256 comparison after a write. + +Errors are printed to stderr as `error: `. For commands that take `--json`, errors are +instead a single JSON object on stderr: + +```json +{"error": {"kind": "confirmation_required", "exit_code": 3, "message": "..."}} +``` + +`kind` is one of `error`, `usage`, `confirmation_required`, `verification_failed`. + +## Scripting notes + +- Machine-readable output goes to stdout; progress and diagnostics go to stderr. `--json` + commands print one pretty-printed JSON value and nothing else on stdout. +- `--json-progress` (on `download`, `write`, `flash`) switches stdout to newline-delimited JSON + events: `{"event":"progress","data":{...}}`, then exactly one terminal event, either + `{"event":"finished"}` or `{"event":"failed","data":{"message","kind","exit_code"}}`. A + `write`/`flash` without `--confirm` emits `{"event":"confirmation_required","data":{"confirmation_phrase","plan"}}` + instead of the text plan. +- Targets are given by device id or path exactly as `bootable devices` prints them. Re-resolve the + target immediately before a write; ids are the more stable handle across re-plugs. +- Writing needs elevated rights. The CLI asks the platform's authorization helper (pkexec, UAC, or + the macOS authorization dialog) when it is not already privileged. +- Shell completions are generated from the live command definition, so they always match the + installed version. + +## The confirmation model + +Writing erases the target, so every write path goes through one gate: + +1. You name the target explicitly. Nothing is ever auto-selected, and `flash` and `write` have no + "first removable drive" shortcut. +2. The target must pass the eligibility checks in the planner: removable, writable, and not the + system disk. Ineligible targets are refused with exit 3. +3. A plan is built and its destructive steps are listed. The plan carries an exact confirmation + phrase (for example `ERASE /dev/sdb ...`). +4. The write only starts if `--confirm` repeats that phrase byte for byte. Without it, the plan + and the phrase are printed and the command exits 3 without touching the device. A wrong phrase + also exits 3. + +The usual automation pattern is therefore two runs: one dry run to read the phrase from the plan +(`bootable plan --json`, field `confirmation_phrase`, or the exit-3 output of `write`/`flash`), +then a second run with `--confirm`. The phrase is derived from the device, so a script should read +it from the plan for the specific device rather than hard-coding it, and a human or policy layer +should approve it. + +## Commands + +### catalog + +List popular distributions from DistroWatch (an interest ranking, not market share). + +```sh +bootable catalog --limit 10 +bootable catalog --json | jq -r '.[].slug' +``` + +### releases + +Resolve current ISO downloads for a catalog slug. Indexes shown here are what `--index` takes in +`download` and `flash`. + +```sh +bootable releases linuxmint +bootable releases linuxmint --json +``` + +### download + +Download a catalog ISO, verify it against the publisher checksum, and inspect it. Exit 4 if the +checksum does not match (the staged file is discarded). + +```sh +bootable download linuxmint --index 0 --output ~/Downloads/mint.iso +bootable download linuxmint --json-progress +``` + +### pi-images and pi-download + +List and fetch official Raspberry Pi Imager images; `pi-download` verifies and extracts. + +```sh +bootable pi-images --device pi5 --limit 10 --json +bootable pi-download 0 --output raspios.img +``` + +### devices + +List removable media. System and internal disks are not offered as targets. + +```sh +bootable devices +bootable devices --json | jq -r '.[] | select(.removable) | .id' +``` + +### inspect + +Identify an image (kind, size, warnings) without reading it onto a device. + +```sh +bootable inspect ubuntu.iso --json +``` + +### checksum + +Compute a digest. `--algorithm` defaults to `sha256`. + +```sh +bootable checksum ubuntu.iso +bootable checksum ubuntu.iso --algorithm sha256 --json +``` + +### backup + +Copy a device to an image file before you erase it. Refuses to overwrite an existing file. + +```sh +bootable backup /dev/sdb sdb-backup.img +``` + +### plan + +Show exactly what a write would do, including the confirmation phrase. Read-only. + +```sh +bootable plan ubuntu.iso /dev/sdb +bootable plan ubuntu.iso /dev/sdb --json | jq -r .confirmation_phrase +bootable plan win11.iso /dev/sdb --windows-partition-scheme gpt --bad-block-check off +``` + +Windows installer options (`--windows-partition-scheme`, `--bypass-windows-11-requirements`, +`--allow-windows-offline-account`, `--windows-local-account`, `--copy-windows-regional-options`, +`--minimize-windows-data-collection`, `--disable-windows-bitlocker`, `--windows-quality-of-life`, +`--use-windows-ca-2023`, `--apply-windows-skusi-policy`, `--force-windows-s-mode`) and +`--bad-block-check off|1|2|4` are shared by `plan`, `write`, and `flash`. + +### write + +Write and verify a local image. Without `--confirm` it prints the plan and exits 3. + +```sh +bootable write ubuntu.iso /dev/sdb # prints plan, exit 3 +bootable write ubuntu.iso /dev/sdb --confirm 'ERASE ...' # writes and verifies +``` + +### flash + +One step from source to bootable media. `SLUG_OR_IMAGE` is a local file or a catalog slug. +`TARGET` is required and is never inferred. + +- A value that is an existing file, or looks like a path (contains `/`, `\`, or `.`, or starts + with `~`), is treated as a local image. A bare word such as `linuxmint` is a catalog slug. +- For a slug, the target is checked for existence and eligibility before any download starts. + Then the release (`--index`, default 0) is downloaded to `--output` (default: the release file + name in the current directory) and verified exactly as `download` does. +- The image is then planned and written through the same confirmation gate as `write`, so it + exits 3 without `--confirm` and the downloaded file is kept. Re-run with the printed path to + skip the download. + +```sh +# Local image +bootable flash ubuntu.iso /dev/sdb +bootable flash ubuntu.iso /dev/sdb --confirm 'ERASE ...' + +# Catalog slug +bootable flash linuxmint /dev/sdb --index 0 --output mint.iso +bootable flash mint.iso /dev/sdb --confirm 'ERASE ...' + +# Unattended with structured progress +bootable flash mint.iso /dev/sdb --confirm "$PHRASE" --json-progress +``` + +On success the command prints `Done: written and verified on ` (or the +`finished` event with `--json-progress`). A failed byte comparison exits 4. + +### completions + +Print a completion script for `bash`, `zsh`, `fish`, `powershell`, or `elvish`. + +```sh +bootable completions bash > ~/.local/share/bash-completion/completions/bootable +bootable completions zsh > "${fpath[1]}/_bootable" +bootable completions fish > ~/.config/fish/completions/bootable.fish +bootable completions powershell >> $PROFILE +bootable completions elvish > ~/.config/elvish/lib/bootable.elv +``` From 91b4e00d2d490aa65b1ca73539412bc2dfa54d42 Mon Sep 17 00:00:00 2001 From: debpalash <4178343+debpalash@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:31:29 +0530 Subject: [PATCH 03/53] site: add honest comparison, Rufus-alternative, and roadmap pages --- site/public/sitemap.xml | 38 ++- site/src/components/ComparePage.astro | 98 +++++++ site/src/components/CompareTable.astro | 39 +++ site/src/components/Header.astro | 3 +- site/src/data/compare.ts | 253 ++++++++++++++++++ site/src/data/jsonld.ts | 46 ++++ site/src/layouts/Layout.astro | 16 +- site/src/pages/bootable-vs-balenaetcher.astro | 4 + .../bootable-vs-raspberry-pi-imager.astro | 4 + site/src/pages/bootable-vs-rufus.astro | 4 + site/src/pages/bootable-vs-ventoy.astro | 4 + site/src/pages/compare.astro | 77 ++++++ site/src/pages/download.astro | 3 +- site/src/pages/faq.astro | 4 +- site/src/pages/features.astro | 4 +- site/src/pages/guides.astro | 1 + site/src/pages/index.astro | 4 +- site/src/pages/roadmap.astro | 95 +++++++ .../pages/rufus-alternative-linux-macos.astro | 77 ++++++ site/src/styles.css | 74 +++++ 20 files changed, 836 insertions(+), 12 deletions(-) create mode 100644 site/src/components/ComparePage.astro create mode 100644 site/src/components/CompareTable.astro create mode 100644 site/src/data/compare.ts create mode 100644 site/src/data/jsonld.ts create mode 100644 site/src/pages/bootable-vs-balenaetcher.astro create mode 100644 site/src/pages/bootable-vs-raspberry-pi-imager.astro create mode 100644 site/src/pages/bootable-vs-rufus.astro create mode 100644 site/src/pages/bootable-vs-ventoy.astro create mode 100644 site/src/pages/compare.astro create mode 100644 site/src/pages/roadmap.astro create mode 100644 site/src/pages/rufus-alternative-linux-macos.astro diff --git a/site/public/sitemap.xml b/site/public/sitemap.xml index 4e43e41..918a7e8 100644 --- a/site/public/sitemap.xml +++ b/site/public/sitemap.xml @@ -2,23 +2,23 @@ https://bootable.palash.dev/ - 2026-08-27 + 2026-10-04 https://bootable.palash.dev/guides - 2026-08-27 + 2026-10-04 https://bootable.palash.dev/features - 2026-08-27 + 2026-10-04 https://bootable.palash.dev/download - 2026-08-27 + 2026-10-04 https://bootable.palash.dev/faq - 2026-08-27 + 2026-10-04 https://bootable.palash.dev/windows-11-usb-on-linux @@ -56,4 +56,32 @@ https://bootable.palash.dev/omarchy-plugin 2026-08-28 + + https://bootable.palash.dev/compare + 2026-10-04 + + + https://bootable.palash.dev/bootable-vs-rufus + 2026-10-04 + + + https://bootable.palash.dev/bootable-vs-balenaetcher + 2026-10-04 + + + https://bootable.palash.dev/bootable-vs-ventoy + 2026-10-04 + + + https://bootable.palash.dev/bootable-vs-raspberry-pi-imager + 2026-10-04 + + + https://bootable.palash.dev/rufus-alternative-linux-macos + 2026-10-04 + + + https://bootable.palash.dev/roadmap + 2026-10-04 + diff --git a/site/src/components/ComparePage.astro b/site/src/components/ComparePage.astro new file mode 100644 index 0000000..5b13673 --- /dev/null +++ b/site/src/components/ComparePage.astro @@ -0,0 +1,98 @@ +--- +import Layout from '../layouts/Layout.astro'; +import Breadcrumbs from './Breadcrumbs.astro'; +import CompareTable from './CompareTable.astro'; +import { competitors, bootableGaps, CHECKED_ON, CHECKED_ON_LABEL } from '../data/compare'; +import { bootableApp, breadcrumbs, webPage } from '../data/jsonld'; + +interface Props { + id: keyof typeof competitors; +} +const { id } = Astro.props; +const c = competitors[id]; +const path = `/${c.slug}`; +const title = `Bootable vs ${c.name}: an honest comparison`; +const description = `Factual comparison of Bootable and ${c.name}: platforms, interfaces, verification, drive safety, licensing, and what each cannot do. Sources linked, checked ${CHECKED_ON_LABEL}.`; + +const page = { + title: `${title} — Bootable`, + description, + ogTitle: title, + ogDescription: `Platforms, verification, safety, license, and gaps for Bootable and ${c.name}, with sources.`, + ogType: 'article' as const, + current: 'compare' as const, + jsonLd: [ + bootableApp(), + webPage(title, path, description, CHECKED_ON), + breadcrumbs([{ name: 'Home', path: '/' }, { name: 'Compare', path: '/compare' }, { name: `Bootable vs ${c.name}` }]), + ], + crumbs: [{ label: 'Home', href: '/' }, { label: 'Compare', href: '/compare' }, { label: `vs ${c.name}` }], +}; +--- + +
+ +
+
+

Compare · Checked on {CHECKED_ON_LABEL}

+

Bootable vs {c.name}

+

{c.summary} Bootable is early-stage software, and this page says where it falls short.

+
+
+
+

In short

+

Choose {c.name} if:

+
    {c.theyFit.map((t) =>
  • {t}
  • )}
+

Choose Bootable if:

+
    {c.bootableFits.map((t) =>
  • {t}
  • )}
+ +

Side-by-side

+

Checked on . Bracketed numbers link to the {c.name} source for that claim. Where a claim could not be verified from official pages, the cell says so rather than guessing.

+ + +

Where {c.name} is stronger

+
    + { + c.theyDoBetter.map((t) => ( +
  • + {t.text} + {(t.src ?? []).map((n) => ( + [{n}] + ))} +
  • + )) + } +
+ +

Bootable’s current gaps

+
+
    {bootableGaps.map((g) =>
  • {g}
  • )}
+
+

See the roadmap for what is planned. Planned items are not shipped.

+ +

Sources

+

All {c.name} claims on this page come from these official pages, read on {CHECKED_ON_LABEL}.

+
    + {c.sources.map((s) =>
  1. {s.label}
  2. )} +
+

Claims about Bootable come from its implementation status, features, and download pages.

+

Bootable is not affiliated with {c.name}. Software changes; check the official site before deciding. Found an error? Open an issue and we will correct it.

+ + +

Try Bootable

Use a drive you can erase. Releases are unsigned today.

Download Bootable
+
+ +
+
+
+
diff --git a/site/src/components/CompareTable.astro b/site/src/components/CompareTable.astro new file mode 100644 index 0000000..85c67ef --- /dev/null +++ b/site/src/components/CompareTable.astro @@ -0,0 +1,39 @@ +--- +import { rows, bootable, type Competitor, type Cell } from '../data/compare'; +interface Props { + competitor: Competitor; +} +const { competitor } = Astro.props; +const cellFor = (cell: Cell) => ({ + text: cell.text, + refs: (cell.src ?? []).map((n) => ({ n, url: competitor.sources[n - 1].url })), +}); +--- +
+ + + + + + + { + rows.map((row) => { + const b = cellFor(bootable[row.key]); + const c = cellFor(competitor.cells[row.key]); + return ( + + + + + + ); + }) + } + +
Bootable compared with {competitor.name}
FeatureBootable{competitor.name}
{row.label}{b.text} + {c.text} + {c.refs.map((r) => ( + [{r.n}] + ))} +
+
diff --git a/site/src/components/Header.astro b/site/src/components/Header.astro index 5ed5d39..e40f544 100644 --- a/site/src/components/Header.astro +++ b/site/src/components/Header.astro @@ -3,7 +3,7 @@ interface Props { /** Home page variant: brand anchors to #top, nav omits Home. */ home?: boolean; /** Active nav item. */ - current?: 'features' | 'guides' | 'download' | 'faq' | 'guide'; + current?: 'features' | 'guides' | 'download' | 'faq' | 'guide' | 'compare' | 'roadmap'; } const { home = false, current } = Astro.props; const homeHref = home ? '#top' : '/'; @@ -18,6 +18,7 @@ const homeHref = home ? '#top' : '/'; {!home && home} features guides + compare source ↗ download ↓ diff --git a/site/src/data/compare.ts b/site/src/data/compare.ts new file mode 100644 index 0000000..52188b7 --- /dev/null +++ b/site/src/data/compare.ts @@ -0,0 +1,253 @@ +// Comparison data. Every competitor claim carries source indexes that point at +// the competitor's own site, repository, or documentation. A cell with no +// source index is either a statement about Bootable or an explicit +// "not checked" marker; unverified competitor claims are left out. + +export const CHECKED_ON = '2026-10-04'; +export const CHECKED_ON_LABEL = '4 October 2026'; + +export interface Cell { + text: string; + /** 1-based indexes into the competitor's `sources` array. */ + src?: number[]; +} +export interface Source { + label: string; + url: string; +} +export interface Competitor { + slug: string; + name: string; + /** Short neutral description of what the tool is for. */ + summary: string; + sources: Source[]; + cells: Record; + /** Things the competitor does that Bootable does not (all sourced). */ + theyDoBetter: Cell[]; + /** Where Bootable is the better fit (factual, scoped). */ + bootableFits: string[]; + /** When to pick the competitor. */ + theyFit: string[]; +} + +export type RowKey = + | 'platforms' + | 'interfaces' + | 'catalog' + | 'readback' + | 'sysdisk' + | 'telemetry' + | 'license' + | 'windows' + | 'bios' + | 'persistence' + | 'multiboot' + | 'signed'; + +export const rows: { key: RowKey; label: string }[] = [ + { key: 'platforms', label: 'Platforms' }, + { key: 'interfaces', label: 'Interfaces' }, + { key: 'catalog', label: 'Built-in image catalog or downloads' }, + { key: 'readback', label: 'Read-back verification of the write' }, + { key: 'sysdisk', label: 'System-disk protection' }, + { key: 'telemetry', label: 'Telemetry and ads' }, + { key: 'license', label: 'License' }, + { key: 'windows', label: 'Windows installer media options' }, + { key: 'bios', label: 'Legacy BIOS boot' }, + { key: 'persistence', label: 'Persistence' }, + { key: 'multiboot', label: 'Multiboot' }, + { key: 'signed', label: 'Signed builds' }, +]; + +export const bootable: Record = { + platforms: { text: 'Linux x86-64, Windows 10+ x86-64, macOS on Apple Silicon.' }, + interfaces: { text: 'Desktop GUI, terminal UI, and CLI. The GUI and TUI expose the same features.' }, + catalog: { text: 'Yes. DistroWatch, Omarchy, and Raspberry Pi catalogs, with publisher checksum verification. Official Microsoft ISO and UEFI Shell downloads are planned, not shipped.' }, + readback: { text: 'Yes for raw images: the written range is read back and compared by SHA-256. Windows installer media gets a boot-tree check instead.' }, + sysdisk: { text: 'Accepts only whole USB or removable drives. Rejects fixed, system, partition, read-only, and undersized targets, and re-finds the drive by stable ID before writing.' }, + telemetry: { text: 'No ads. No telemetry code in the source. Network use is for catalogs and downloads.' }, + license: { text: 'Apache-2.0.' }, + windows: { text: 'GPT or MBR FAT32 UEFI media with split-WIM support on Linux, Windows, and macOS. Setup options: TPM, Secure Boot, and RAM checks; account, region, privacy, BitLocker, quality-of-life, CA 2023, SkuSiPolicy, and S Mode.' }, + bios: { text: 'No. UEFI only today. Legacy BIOS helpers are planned.' }, + persistence: { text: 'No. Planned.' }, + multiboot: { text: 'No. Planned as a separate, explicit strategy.' }, + signed: { text: 'No. Releases are not code-signed and the macOS build is not notarized. SHA-256 files and GitHub provenance are published.' }, +}; + +export const bootableGaps = [ + 'No legacy BIOS boot yet (UEFI only).', + 'No Linux persistence and no multiboot.', + 'No general formatting (FAT, NTFS, UDF, exFAT, ext), Windows To Go, or DOS media.', + 'Backup is raw IMG/RAW/DD only; VHD, VHDX, and FFU are planned. Windows RAW backup is unavailable.', + 'Unsigned builds today, and no localization.', + 'Early-stage software: v0.1.1, with only the latest release supported.', +]; + +const NA = { text: 'Not checked for this page.' }; + +export const competitors: Record = { + rufus: { + slug: 'bootable-vs-rufus', + name: 'Rufus', + summary: 'Rufus is a long-established Windows utility for creating bootable USB drives.', + sources: [ + { label: 'Rufus home page (rufus.ie)', url: 'https://rufus.ie/en/' }, + { label: 'Rufus README: feature list and license', url: 'https://github.com/pbatard/rufus/blob/master/README.md' }, + { label: 'Rufus FAQ', url: 'https://github.com/pbatard/rufus/wiki/FAQ' }, + ], + cells: { + platforms: { text: 'Windows 8 or later (x64, x86, ARM64 builds). The FAQ says there are no plans to port Rufus to other operating systems.', src: [1, 3] }, + interfaces: { text: 'Graphical interface. CLI and TUI: not checked.', src: [2] }, + catalog: { text: 'Downloads official Microsoft Windows 8, 10, and 11 retail ISOs and UEFI Shell ISOs. No Linux distribution catalog listed.', src: [2] }, + readback: NA, + sysdisk: NA, + telemetry: { text: 'The home page states the website shows ads. App telemetry: not stated in the pages checked.', src: [1] }, + license: { text: 'GPL v3 or later.', src: [1, 2] }, + windows: { text: 'Windows 11 media for PCs without TPM or Secure Boot, OOBE setup (local account, privacy options), and Windows To Go.', src: [2] }, + bios: { text: 'Yes. Creates BIOS or UEFI bootable drives.', src: [2] }, + persistence: { text: 'Yes. Creates persistent Linux partitions.', src: [2] }, + multiboot: { text: 'Not listed in its feature list.', src: [2] }, + signed: { text: 'Yes. Binaries are signed with a Windows Authenticode signature.', src: [3] }, + }, + theyDoBetter: [ + { text: 'Formats drives to FAT, FAT32, NTFS, UDF, exFAT, ReFS, and ext2/3.', src: [2] }, + { text: 'Creates DOS (FreeDOS or MS-DOS) bootable drives and Windows To Go drives.', src: [2] }, + { text: 'Creates VHD/DD, VHDX, and FFU images of an existing drive.', src: [2] }, + { text: 'Natively supports 38 languages.', src: [2] }, + { text: 'Authenticode-signed builds.', src: [3] }, + ], + bootableFits: [ + 'You are on Linux or macOS, where Rufus does not run.', + 'You want one tool on Linux, Windows, and macOS with the same interface and behavior.', + 'You prefer a terminal UI or CLI alongside the GUI.', + 'You want a distribution catalog with publisher checksum verification inside the tool.', + ], + theyFit: [ + 'You are on Windows and need legacy BIOS boot, persistence, formatting, DOS media, or Windows To Go.', + 'You need signed binaries today.', + 'You need a localized interface.', + ], + }, + etcher: { + slug: 'bootable-vs-balenaetcher', + name: 'balenaEtcher', + summary: 'balenaEtcher is a cross-platform image flasher with a three-step graphical interface.', + sources: [ + { label: 'balenaEtcher home page', url: 'https://etcher.balena.io/' }, + { label: 'balenaEtcher README', url: 'https://github.com/balena-io/etcher/blob/master/README.md' }, + { label: 'balenaEtcher repository (license)', url: 'https://github.com/balena-io/etcher' }, + { label: 'balenaEtcher changelog', url: 'https://raw.githubusercontent.com/balena-io/etcher/master/CHANGELOG.md' }, + ], + cells: { + platforms: { text: 'Linux, Windows 10 and later, and macOS (Intel and Apple Silicon). Linux and Windows builds are 64-bit Intel per the README.', src: [1, 2] }, + interfaces: { text: 'Graphical interface (Electron). CLI and TUI: not checked.', src: [1] }, + catalog: { text: 'Not described on its site or README.', src: [1, 2] }, + readback: { text: 'Yes. Its site lists "Validated Flashing" to confirm the write completed correctly.', src: [1] }, + sysdisk: { text: 'Yes. Its site says it warns users and hides system drives by default.', src: [1] }, + telemetry: { text: 'The changelog records analytics removal in v2.1.2 (2025-05-08) and earlier opt-out error and usage reporting. We did not test what the current build sends.', src: [4] }, + license: { text: 'Apache-2.0.', src: [3] }, + windows: { text: 'None described; it writes the image you supply.', src: [1, 2] }, + bios: { text: 'Not described.', src: [1, 2] }, + persistence: { text: 'Not described.', src: [1, 2] }, + multiboot: { text: 'Not described.', src: [1, 2] }, + signed: NA, + }, + theyDoBetter: [ + { text: 'Flashes several drives at once.', src: [1] }, + { text: 'Offers package-manager installs: apt/deb, yum, pacman, WinGet, and Chocolatey.', src: [2] }, + { text: 'Supports Intel Macs as well as Apple Silicon.', src: [2] }, + { text: 'Is a mature, widely used flasher with a deliberately small three-step interface.', src: [1] }, + ], + bootableFits: [ + 'You want a catalog and verified downloads inside the same tool.', + 'You need UEFI Windows installer media with setup options, not only a raw image write.', + 'You want a terminal UI or CLI.', + 'You want a statement that the source contains no telemetry code.', + ], + theyFit: [ + 'You flash many drives at once.', + 'You run an Intel Mac or want a package-manager install on Windows.', + 'You only need a simple, established image writer.', + ], + }, + ventoy: { + slug: 'bootable-vs-ventoy', + name: 'Ventoy', + summary: 'Ventoy takes a different approach: it installs a boot menu once, and you copy ISO files onto the drive.', + sources: [ + { label: 'Ventoy home page', url: 'https://www.ventoy.net/en/index.html' }, + { label: 'Ventoy quick start', url: 'https://www.ventoy.net/en/doc_start.html' }, + { label: 'Ventoy repository (license)', url: 'https://github.com/ventoy/Ventoy' }, + ], + cells: { + platforms: { text: 'Installer documented for Windows (Ventoy2Disk.exe) and Linux (Ventoy2Disk.sh). A macOS installer is not documented in the pages checked. Boots x86 Legacy BIOS, IA32/x86_64/ARM64/MIPS64EL UEFI machines.', src: [1, 2] }, + interfaces: { text: 'Windows installer program and a Linux shell script. Other interfaces: not checked.', src: [2] }, + catalog: { text: 'Not described. You copy image files onto the drive yourself.', src: [1, 2] }, + readback: { text: 'Not described. Ventoy copies files rather than raw-writing an image.', src: [2] }, + sysdisk: NA, + telemetry: NA, + license: { text: 'GPL-3.0.', src: [3] }, + windows: { text: 'Boots Windows ISO, WIM, and VHD(x) files directly with no extraction. Windows setup options: not described.', src: [1] }, + bios: { text: 'Yes. Supports Legacy BIOS and UEFI.', src: [1, 2] }, + persistence: { text: 'Yes. Linux persistence is supported.', src: [1, 3] }, + multiboot: { text: 'Yes. Copy several images to the drive and pick one from a boot menu.', src: [1, 2] }, + signed: { text: 'Secure Boot is supported for IA32 and x86_64 UEFI. Signing of the installer itself: not checked.', src: [1] }, + }, + theyDoBetter: [ + { text: 'Carries many images on one drive and boots them from a menu without rewriting the drive.', src: [1, 2] }, + { text: 'Boots in Legacy BIOS and UEFI, including Secure Boot, on several architectures.', src: [1, 2] }, + { text: 'Supports Linux persistence.', src: [1, 3] }, + { text: 'Keeps the drive usable for ordinary files; updates preserve files in the data partition.', src: [1, 2] }, + ], + bootableFits: [ + 'You want a single image written to a drive and verified by read-back, with the drive left as that image.', + 'You want a built-in catalog with publisher checksum verification.', + 'You want a macOS or terminal workflow for creating Windows installer media.', + ], + theyFit: [ + 'You keep many ISOs on one drive and choose at boot time.', + 'You need legacy BIOS, persistence, or Secure Boot support for the boot menu.', + 'You often swap images and do not want to rewrite the drive each time.', + ], + }, + rpi: { + slug: 'bootable-vs-raspberry-pi-imager', + name: 'Raspberry Pi Imager', + summary: 'Raspberry Pi Imager is the Raspberry Pi project’s tool for writing operating systems to storage for Raspberry Pi devices.', + sources: [ + { label: 'Raspberry Pi software page', url: 'https://www.raspberrypi.com/software/' }, + { label: 'Raspberry Pi documentation: getting started', url: 'https://www.raspberrypi.com/documentation/computers/getting-started.html' }, + { label: 'rpi-imager README (telemetry, install)', url: 'https://github.com/raspberrypi/rpi-imager/blob/main/README.md' }, + { label: 'rpi-imager license file', url: 'https://raw.githubusercontent.com/raspberrypi/rpi-imager/main/license.txt' }, + { label: 'rpi-imager issue #323 ("Verifying write failed")', url: 'https://github.com/raspberrypi/rpi-imager/issues/323' }, + ], + cells: { + platforms: { text: 'Windows, macOS, and Linux (x86_64). Also installable on Raspberry Pi OS with apt.', src: [1, 3] }, + interfaces: { text: 'Graphical interface. CLI and TUI: not checked.', src: [2] }, + catalog: { text: 'Yes. Includes many OS images for Raspberry Pi devices and can write a custom image from your computer.', src: [2] }, + readback: { text: 'Yes. It runs a verification step after writing and reports "Verifying write failed" on a mismatch.', src: [5] }, + sysdisk: NA, + telemetry: { text: 'Collects anonymous usage statistics (selected OS, Imager version, host OS details). Opt out under App Options.', src: [3] }, + license: { text: 'Apache-2.0 for the main code; the license file also lists third-party components such as Qt under LGPL v3.', src: [4] }, + windows: { text: 'Not applicable: it targets Raspberry Pi devices.', src: [3] }, + bios: { text: 'Not described (Raspberry Pi focus).', src: [3] }, + persistence: { text: 'Not described.', src: [2, 3] }, + multiboot: { text: 'Not described.', src: [2, 3] }, + signed: NA, + }, + theyDoBetter: [ + { text: 'Preconfigures hostname, timezone, user account, Wi-Fi, and SSH before first boot.', src: [2] }, + { text: 'Lets you pick a device model and shows the recommended OS for it at the top of the list.', src: [2] }, + { text: 'Is the Raspberry Pi project’s own tool, with a catalog maintained for its devices.', src: [2, 3] }, + ], + bootableFits: [ + 'You write more than Raspberry Pi images: Linux ISOs, other raw images, or Windows installer media.', + 'You want one tool for USB drives and SD cards across Linux, Windows, and macOS.', + 'You want terminal and CLI interfaces and no telemetry code in the source.', + ], + theyFit: [ + 'You are setting up a Raspberry Pi and want Wi-Fi, SSH, and user settings preconfigured.', + 'You want the official, device-aware catalog from the Raspberry Pi project.', + ], + }, +}; diff --git a/site/src/data/jsonld.ts b/site/src/data/jsonld.ts new file mode 100644 index 0000000..5d946cb --- /dev/null +++ b/site/src/data/jsonld.ts @@ -0,0 +1,46 @@ +const SITE = 'https://bootable.palash.dev'; + +/** SoftwareApplication entry for Bootable. Only facts stated on the site. */ +export function bootableApp() { + return { + '@context': 'https://schema.org', + '@type': 'SoftwareApplication', + name: 'Bootable', + url: SITE + '/', + applicationCategory: 'UtilitiesApplication', + operatingSystem: 'Linux, Windows 10+, macOS (Apple Silicon)', + softwareVersion: '0.1.1', + license: 'https://www.apache.org/licenses/LICENSE-2.0', + isAccessibleForFree: true, + downloadUrl: SITE + '/download', + codeRepository: 'https://github.com/debpalash/bootable', + description: + 'Open-source USB and SD image writer for Linux, Windows, and macOS with read-back verification and removable-drive-only safety checks.', + offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' }, + }; +} + +export function breadcrumbs(items: { name: string; path?: string }[]) { + return { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: items.map((item, i) => ({ + '@type': 'ListItem', + position: i + 1, + name: item.name, + ...(item.path !== undefined ? { item: SITE + item.path } : {}), + })), + }; +} + +export function webPage(name: string, path: string, description: string, dateModified: string) { + return { + '@context': 'https://schema.org', + '@type': 'WebPage', + name, + url: SITE + path, + description, + dateModified, + isPartOf: { '@type': 'WebSite', name: 'Bootable', url: SITE + '/' }, + }; +} diff --git a/site/src/layouts/Layout.astro b/site/src/layouts/Layout.astro index f0c7fde..449de8b 100644 --- a/site/src/layouts/Layout.astro +++ b/site/src/layouts/Layout.astro @@ -12,7 +12,9 @@ interface Props { /** Index page: full twitter card and social-card image meta. */ home?: boolean; noindex?: boolean; - current?: 'features' | 'guides' | 'download' | 'faq' | 'guide'; + /** JSON-LD structured data object(s), emitted as application/ld+json. */ + jsonLd?: object | object[]; + current?: 'features' | 'guides' | 'download' | 'faq' | 'guide' | 'compare' | 'roadmap'; /** Fifth footer link for guide pages; defaults to Safety. */ doc?: FooterLink; /** Explicit footer links; overrides the per-page defaults. */ @@ -26,6 +28,7 @@ const { ogType = 'website', home = false, noindex = false, + jsonLd, current, doc, footerLinks: footerProp, @@ -47,6 +50,8 @@ const footerLinks: FooterLink[] = footerProp ?? (home { label: 'Guides', href: '/guides' }, { label: 'Download', href: '/download' }, { label: 'FAQ', href: '/faq' }, + { label: 'Compare', href: '/compare' }, + { label: 'Roadmap', href: '/roadmap' }, { label: 'Changelog', href: `${GITHUB}/blob/main/CHANGELOG.md` }, { label: 'Source', href: GITHUB }, ] @@ -55,6 +60,7 @@ const footerLinks: FooterLink[] = footerProp ?? (home { label: 'Home', href: '/' }, { label: 'Download', href: '/download' }, { label: 'FAQ', href: '/faq' }, + { label: 'Compare', href: '/compare' }, { label: 'Safety', href: docs('safety.md') }, { label: 'Source', href: GITHUB }, ] @@ -63,6 +69,7 @@ const footerLinks: FooterLink[] = footerProp ?? (home { label: 'Features', href: '/features' }, { label: 'Guides', href: '/guides' }, { label: 'Download', href: '/download' }, + { label: 'Compare', href: '/compare' }, { label: 'Safety', href: docs('safety.md') }, { label: 'Source', href: GITHUB }, ] @@ -71,6 +78,7 @@ const footerLinks: FooterLink[] = footerProp ?? (home { label: 'Guides', href: '/guides' }, { label: 'Download', href: '/download' }, { label: 'FAQ', href: '/faq' }, + { label: 'Compare', href: '/compare' }, doc ?? { label: 'Safety', href: docs('safety.md') }, { label: 'Source', href: GITHUB }, ]); @@ -113,6 +121,12 @@ const footerLinks: FooterLink[] = footerProp ?? (home + { + jsonLd && + (Array.isArray(jsonLd) ? jsonLd : [jsonLd]).map((entry) => ( +