use adw::prelude::*; use gtk::glib; use std::{cell::RefCell, fs, path::Path, rc::Rc}; const APP_ID: &str = "org.altlinux.Flasher"; const BOARDS: &str = include_str!("../alt-rootfs-installer/SUPPORTED-BOARDS"); #[derive(Default)] struct State { source: Option, source_is_image: bool, output_file: Option, output_is_file: bool, } fn main() -> glib::ExitCode { let app = adw::Application::builder().application_id(APP_ID).build(); app.connect_activate(build_ui); app.run() } fn build_ui(app: &adw::Application) { let state = Rc::new(RefCell::new(State::default())); let window = adw::ApplicationWindow::builder() .application(app) .title("ALT Image Writer") .default_width(720) .default_height(760) .build(); let toast_overlay = adw::ToastOverlay::new(); let toolbar_view = adw::ToolbarView::new(); toast_overlay.set_child(Some(&toolbar_view)); let header = adw::HeaderBar::new(); let about_button = gtk::Button::builder() .icon_name("help-about-symbolic") .tooltip_text("About ALT Image Writer") .build(); header.pack_end(&about_button); toolbar_view.add_top_bar(&header); let page = adw::PreferencesPage::new(); page.set_title("Create Installation Media"); page.set_description( "Prepare an ALT Linux root filesystem or disk image for your board.", ); toolbar_view.set_content(Some(&page)); // Source let source_group = adw::PreferencesGroup::builder() .title("Installation Source") .description("Choose a root filesystem archive or a ready-made disk image.") .build(); let source_type_row = adw::ActionRow::builder().title("Source type").build(); let source_types = gtk::Box::new(gtk::Orientation::Horizontal, 0); source_types.add_css_class("linked"); source_types.set_valign(gtk::Align::Center); let archive_button = gtk::ToggleButton::with_label("Root filesystem"); archive_button.set_active(true); let image_button = gtk::ToggleButton::with_label("Disk image"); image_button.set_group(Some(&archive_button)); source_types.append(&archive_button); source_types.append(&image_button); source_type_row.add_suffix(&source_types); source_group.add(&source_type_row); let source_row = adw::ActionRow::builder() .title("No source selected") .subtitle("Supported: .tar, .tar.gz, .tar.xz") .activatable(true) .build(); source_row.add_prefix(&image("document-open-symbolic")); let source_choose = gtk::Button::builder() .label("Choose…") .valign(gtk::Align::Center) .build(); source_row.add_suffix(&source_choose); source_row.set_activatable_widget(Some(&source_choose)); source_group.add(&source_row); page.add(&source_group); // Hardware let hardware_group = adw::PreferencesGroup::builder() .title("Target Hardware") .description("The matching bootloader and device tree will be installed.") .build(); let boards = supported_boards(); let board_count = boards.len(); // ComboRow does not have native section headers, so insert non-selectable, // clearly marked headings while keeping the actual board labels compact. let mut board_names = vec!["Choose a board…".to_owned()]; let mut board_platforms = vec![String::new()]; let mut board_targets = vec![None]; let mut previous_platform = None; for (name, platform) in boards { if previous_platform.as_deref() != Some(platform.as_str()) { board_names.push(format!("── {platform} ──")); board_platforms.push(String::new()); board_targets.push(None); previous_platform = Some(platform.clone()); } board_names.push(name.clone()); board_platforms.push(platform); board_targets.push(Some(name)); } let board_model = gtk::StringList::new( &board_names .iter() .map(String::as_str) .collect::>(), ); let board_row = adw::ComboRow::builder() .title("Target board") .subtitle(format!( "Search or choose from {board_count} supported boards" )) .model(&board_model) .enable_search(true) .build(); hardware_group.add(&board_row); page.add(&hardware_group); // Destination let destination_group = adw::PreferencesGroup::builder() .title("Destination") .description("Write to removable media or create an image file.") .build(); let destination_type_row = adw::ActionRow::builder().title("Output type").build(); let destination_types = gtk::Box::new(gtk::Orientation::Horizontal, 0); destination_types.add_css_class("linked"); destination_types.set_valign(gtk::Align::Center); let drive_button = gtk::ToggleButton::with_label("Drive"); drive_button.set_active(true); let file_button = gtk::ToggleButton::with_label("Image file"); file_button.set_group(Some(&drive_button)); destination_types.append(&drive_button); destination_types.append(&file_button); destination_type_row.add_suffix(&destination_types); destination_group.add(&destination_type_row); let drive_model = gtk::StringList::new( &available_drives() .iter() .map(String::as_str) .collect::>(), ); let drive_row = adw::ComboRow::builder() .title("Storage device") .subtitle("Only removable devices are shown") .model(&drive_model) .build(); destination_group.add(&drive_row); let output_row = adw::ActionRow::builder() .title("Choose where to save the image") .subtitle("Raw disk image (.img)") .visible(false) .activatable(true) .build(); output_row.add_prefix(&image("document-save-symbolic")); let output_choose = gtk::Button::builder() .label("Choose…") .valign(gtk::Align::Center) .build(); output_row.add_suffix(&output_choose); output_row.set_activatable_widget(Some(&output_choose)); destination_group.add(&output_row); page.add(&destination_group); // Options mirroring the command line's commonly used switches. let options_group = adw::PreferencesGroup::builder() .title("Options") .description("Partitioning and first-boot settings") .build(); let filesystem_model = gtk::StringList::new(&["ext4", "f2fs"]); let filesystem_row = adw::ComboRow::builder() .title("Root filesystem") .model(&filesystem_model) .build(); options_group.add(&filesystem_row); options_group.add(&switch_row( "Resize root partition", "Grow the root filesystem to fill the destination", "view-fullscreen-symbolic", true, )); options_group.add(&switch_row( "Separate boot partition", "Create a 512 MiB boot partition", "drive-harddisk-symbolic", false, )); options_group.add(&switch_row( "Encrypt root partition", "Protect the root filesystem with LUKS", "changes-prevent-symbolic", false, )); options_group.add(&switch_row( "Serial console", "Add the system serial console to boot arguments", "utilities-terminal-symbolic", false, )); page.add(&options_group); let advanced_group = adw::PreferencesGroup::builder().title("Advanced").build(); let efi_model = gtk::StringList::new(&["None", "EFI (GPT)", "EFI (MBR)"]); advanced_group.add( &adw::ComboRow::builder() .title("EFI system partition") .model(&efi_model) .build(), ); let vnc_model = gtk::StringList::new(&["Use image default", "Enabled", "Disabled"]); advanced_group.add( &adw::ComboRow::builder() .title("First-boot VNC") .model(&vnc_model) .build(), ); let extra_row = adw::ActionRow::builder() .title("Extra options file") .subtitle("Optional installer configuration") .build(); extra_row.add_suffix( >k::Button::builder() .icon_name("document-open-symbolic") .tooltip_text("Choose extra options file") .valign(gtk::Align::Center) .build(), ); advanced_group.add(&extra_row); page.add(&advanced_group); let action_bar = gtk::Box::new(gtk::Orientation::Horizontal, 12); action_bar.set_margin_top(12); action_bar.set_margin_bottom(12); action_bar.set_margin_start(12); action_bar.set_margin_end(12); let action_hint = gtk::Label::new(Some("Nothing will be written until you confirm.")); action_hint.add_css_class("dim-label"); action_hint.set_hexpand(true); action_hint.set_halign(gtk::Align::Start); let start_button = gtk::Button::with_label("Review and Write"); start_button.add_css_class("suggested-action"); start_button.set_sensitive(false); action_bar.append(&action_hint); action_bar.append(&start_button); toolbar_view.add_bottom_bar(&action_bar); window.set_content(Some(&toast_overlay)); // Interactions { let window = window.clone(); about_button.connect_clicked(move |_| { adw::AboutDialog::builder() .application_name("ALT Image Writer") .application_icon(APP_ID) .developer_name("ALT Linux Team") .version(env!("CARGO_PKG_VERSION")) .comments("Prepare ALT Linux installation media for supported single-board computers.") .website("https://www.altlinux.org") .license_type(gtk::License::Gpl20) .build() .present(Some(&window)); }); } { let source_row = source_row.clone(); let state = state.clone(); let file_button = file_button.clone(); archive_button.connect_toggled(move |button| { if button.is_active() { state.borrow_mut().source_is_image = false; source_row.set_subtitle("Supported: .tar, .tar.gz, .tar.xz"); file_button.set_sensitive(true); } }); } { let source_row = source_row.clone(); let state = state.clone(); let file_button = file_button.clone(); let drive_button = drive_button.clone(); image_button.connect_toggled(move |button| { if button.is_active() { state.borrow_mut().source_is_image = true; source_row.set_subtitle("Supported: .img, .img.xz"); drive_button.set_active(true); file_button.set_sensitive(false); } }); } { let window = window.clone(); let row = source_row.clone(); let state = state.clone(); let board_row = board_row.clone(); let drive_row = drive_row.clone(); let start_button = start_button.clone(); source_choose.connect_clicked(move |_| { let chooser = gtk::FileDialog::builder() .title("Choose Installation Source") .accept_label("Select") .modal(true) .build(); let row = row.clone(); let state = state.clone(); let board_row = board_row.clone(); let drive_row = drive_row.clone(); let start_button = start_button.clone(); chooser.open(Some(&window), gtk::gio::Cancellable::NONE, move |result| { if let Some(path) = result.ok().and_then(|file| file.path()) { row.set_title(&display_name(&path)); row.set_subtitle(path.parent().and_then(Path::to_str).unwrap_or("")); state.borrow_mut().source = Some(path.display().to_string()); update_start(&start_button, &state, &board_row, &drive_row); } }); }); } { let state = state.clone(); let drive_row = drive_row.clone(); let start_button = start_button.clone(); let board_platforms = board_platforms.clone(); let board_targets = board_targets.clone(); board_row.connect_selected_notify(move |row| { let selected = row.selected() as usize; if selected > 0 && board_targets .get(selected) .is_some_and(Option::is_none) { // Section headings are labels, not valid installation targets. row.set_selected(0); return; } if selected == 0 { let count = board_targets.iter().flatten().count(); row.set_subtitle(&format!( "Search or choose from {count} supported boards" )); } else if let Some(platform) = board_platforms.get(selected) { row.set_subtitle(platform); } update_start(&start_button, &state, row, &drive_row) }); } { let state = state.clone(); let board_row = board_row.clone(); let start_button = start_button.clone(); drive_row.connect_selected_notify(move |row| { update_start(&start_button, &state, &board_row, row) }); } { let drive_row = drive_row.clone(); let output_row = output_row.clone(); let state = state.clone(); let board_row = board_row.clone(); let start_button = start_button.clone(); file_button.connect_toggled(move |button| { let file = button.is_active(); drive_row.set_visible(!file); output_row.set_visible(file); state.borrow_mut().output_is_file = file; update_start(&start_button, &state, &board_row, &drive_row); }); } { let window = window.clone(); let row = output_row.clone(); let state = state.clone(); let board_row = board_row.clone(); let drive_row = drive_row.clone(); let start_button = start_button.clone(); output_choose.connect_clicked(move |_| { let chooser = gtk::FileDialog::builder() .title("Create Disk Image") .accept_label("Select") .initial_name("alt-linux.img") .modal(true) .build(); let row = row.clone(); let state = state.clone(); let board_row = board_row.clone(); let drive_row = drive_row.clone(); let start_button = start_button.clone(); chooser.save(Some(&window), gtk::gio::Cancellable::NONE, move |result| { if let Some(path) = result.ok().and_then(|file| file.path()) { row.set_title(&display_name(&path)); row.set_subtitle(path.parent().and_then(Path::to_str).unwrap_or("")); state.borrow_mut().output_file = Some(path.display().to_string()); update_start(&start_button, &state, &board_row, &drive_row); } }); }); } { let window = window.clone(); let toast_overlay = toast_overlay.clone(); let state = state.clone(); let board_row = board_row.clone(); let board_targets = board_targets.clone(); let drive_model = drive_model.clone(); let drive_row = drive_row.clone(); start_button.connect_clicked(move |_| { let state = state.borrow(); let board = board_targets .get(board_row.selected() as usize) .and_then(Option::as_deref) .unwrap_or("Unknown board"); let destination = if state.output_is_file { state .output_file .clone() .unwrap_or_else(|| "No output file".into()) } else { drive_model .string(drive_row.selected()) .map(|s| s.to_string()) .unwrap_or_else(|| "No drive".into()) }; let dialog = adw::AlertDialog::builder() .heading("Ready to write?") .body(format!( "Board: {board}\nDestination: {destination}\n\nThis is an interface preview. No data will be written." )) .build(); dialog.add_response("cancel", "Cancel"); dialog.add_response("write", "Write"); dialog.set_response_appearance("write", adw::ResponseAppearance::Destructive); dialog.set_default_response(Some("cancel")); dialog.set_close_response("cancel"); let toast_overlay = toast_overlay.clone(); dialog.connect_response(Some("write"), move |_, _| { toast_overlay.add_toast(adw::Toast::new("Flashing is not implemented in this preview")); }); dialog.present(Some(&window)); }); } window.present(); } fn image(icon_name: &str) -> gtk::Image { let image = gtk::Image::from_icon_name(icon_name); image.set_pixel_size(20); image } fn switch_row(title: &str, subtitle: &str, icon_name: &str, active: bool) -> adw::SwitchRow { let row = adw::SwitchRow::builder() .title(title) .subtitle(subtitle) .active(active) .build(); row.add_prefix(&image(icon_name)); row } fn display_name(path: &Path) -> String { path.file_name() .and_then(|name| name.to_str()) .unwrap_or("Selected file") .to_owned() } fn update_start( button: >k::Button, state: &Rc>, board: &adw::ComboRow, drive: &adw::ComboRow, ) { let state = state.borrow(); let has_destination = if state.output_is_file { state.output_file.is_some() } else { drive.model().map(|m| m.n_items() > 0).unwrap_or(false) }; button.set_sensitive( state.source.is_some() && board.selected() != gtk::INVALID_LIST_POSITION && board.selected() > 0 && has_destination, ); } fn supported_boards() -> Vec<(String, String)> { let mut result = Vec::new(); let mut family = "Other"; for line in BOARDS.lines() { let line = line.trim(); if line.is_empty() { continue; } if let Some(heading) = line.strip_suffix(':') { family = heading; continue; } for board in line.split_whitespace() { result.push((board.to_owned(), family.to_owned())); } } result } fn available_drives() -> Vec { let mut drives = Vec::new(); let Ok(entries) = fs::read_dir("/sys/block") else { return drives; }; for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().into_owned(); if name.starts_with("loop") || name.starts_with("ram") || name.starts_with("zram") { continue; } let removable = fs::read_to_string(entry.path().join("removable")) .map(|value| value.trim() == "1") .unwrap_or(false); if !removable && !name.starts_with("mmcblk") { continue; } let sectors = fs::read_to_string(entry.path().join("size")) .ok() .and_then(|value| value.trim().parse::().ok()) .unwrap_or(0); let size = format_size(sectors.saturating_mul(512)); let model = fs::read_to_string(entry.path().join("device/model")) .unwrap_or_default() .trim() .to_owned(); if model.is_empty() { drives.push(format!("/dev/{name} · {size}")); } else { drives.push(format!("{model} · {size} · /dev/{name}")); } } drives.sort(); drives } fn format_size(bytes: u64) -> String { const GIB: f64 = 1024.0 * 1024.0 * 1024.0; const MIB: f64 = 1024.0 * 1024.0; if bytes as f64 >= GIB { format!("{:.1} GB", bytes as f64 / GIB) } else { format!("{:.0} MB", bytes as f64 / MIB) } }