From 9808b79f2076026217dbd0027de62b12ad299304 Mon Sep 17 00:00:00 2001 From: Andrew Guschin Date: Sat, 25 Jul 2026 19:32:00 +0400 Subject: add flasher-qt --- Cargo.lock | 188 +++++++++++++- Cargo.toml | 13 +- README.md | 44 ++++ crates/flasher-gtk/Cargo.toml | 10 + crates/flasher-gtk/src/main.rs | 558 +++++++++++++++++++++++++++++++++++++++++ crates/flasher-qt/Cargo.toml | 9 + crates/flasher-qt/qml/Main.qml | 394 +++++++++++++++++++++++++++++ crates/flasher-qt/src/main.rs | 69 +++++ src/main.rs | 558 ----------------------------------------- 9 files changed, 1274 insertions(+), 569 deletions(-) create mode 100644 README.md create mode 100644 crates/flasher-gtk/Cargo.toml create mode 100644 crates/flasher-gtk/src/main.rs create mode 100644 crates/flasher-qt/Cargo.toml create mode 100644 crates/flasher-qt/qml/Main.qml create mode 100644 crates/flasher-qt/src/main.rs delete mode 100644 src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 4af0b09..cb7eb08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -14,6 +23,12 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "cairo-rs" version = "0.21.5" @@ -37,6 +52,16 @@ dependencies = [ "system-deps", ] +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-expr" version = "0.20.8" @@ -47,6 +72,53 @@ dependencies = [ "target-lexicon", ] +[[package]] +name = "cpp" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f6422bf20bc0654eac481a29a03e6d9121ad9f12345a03773b8a76a8701915" +dependencies = [ + "cpp_macros", +] + +[[package]] +name = "cpp_build" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f6fed3200ba0708c2adca5f6ed5ae202edd824bd4cbac7935a85edac9bcddce" +dependencies = [ + "cc", + "cpp_common", + "proc-macro2", + "regex", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "cpp_common" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7280a73ef92e18d27d2ec3005b57fe0043b51d1b506be86b0bf66f588f9857b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "cpp_macros" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc7fd49a5b246251229ea4d4bf94c8d418689a1f9986ef96e00b64992922169" +dependencies = [ + "aho-corasick", + "byteorder", + "cpp_common", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -64,13 +136,26 @@ dependencies = [ ] [[package]] -name = "flasher" +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flasher-gtk" version = "0.1.0" dependencies = [ "gtk4", "libadwaita", ] +[[package]] +name = "flasher-qt" +version = "0.1.0" +dependencies = [ + "qmetaobject", +] + [[package]] name = "futures-channel" version = "0.3.33" @@ -403,6 +488,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libadwaita" version = "0.8.1" @@ -440,6 +531,12 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + [[package]] name = "memchr" version = "2.8.3" @@ -509,6 +606,43 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "qmetaobject" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "426a57e85d36f055a0c82cb0a8a261d49ba051ab2a2ef5471835f69d477816cd" +dependencies = [ + "cpp", + "cpp_build", + "lazy_static", + "log", + "qmetaobject_impl", + "qttypes", + "semver", +] + +[[package]] +name = "qmetaobject_impl" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afc24897c707dcd6963e359e7f2b123857c508f129bed8ac4d3bd575c1a47627" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "qttypes" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7edf5b38c97ad8900ad2a8418ee44b4adceaa866a4a3405e2f1c909871d7ebd" +dependencies = [ + "cpp", + "cpp_build", + "semver", +] + [[package]] name = "quote" version = "1.0.47" @@ -518,6 +652,35 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "rustc_version" version = "0.4.1" @@ -562,6 +725,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "slab" version = "0.4.12" @@ -574,6 +743,17 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.119" @@ -672,6 +852,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "version-compare" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 75f7dc3..01db450 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,3 @@ -[package] -name = "flasher" -version = "0.1.0" -edition = "2024" -description = "A GTK interface for preparing ALT Linux images for single-board computers" -license = "GPL-2.0-or-later" - -[dependencies] -adw = { package = "libadwaita", version = "0.8", features = ["v1_5"] } -gtk = { package = "gtk4", version = "0.10", features = ["v4_10"] } +[workspace] +members = ["crates/flasher-gtk", "crates/flasher-qt"] +resolver = "3" diff --git a/README.md b/README.md new file mode 100644 index 0000000..c2fd06d --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# ALT Image Writer GUI prototypes + +This workspace contains two independent interfaces for `alt-rootfs-installer`: + +- `crates/flasher-gtk` — GTK 4/libadwaita +- `crates/flasher-qt` — Qt 5/Qt Quick Controls 2 + +Both are interface previews. They inspect supported boards and removable drives, but do not write to any device. + +## System dependencies + +On ALT Linux: + +```sh +sudo apt-get install gcc-c++ libgtk4-devel libadwaita-devel \ + qt5-base-devel qt5-declarative-devel qt5-quickcontrols2-devel \ + qt5-quickcontrols2 +``` + +The Qt bindings discover Qt through `qmake`. If several Qt versions are installed, select Qt 5 explicitly: + +```sh +export QMAKE=/usr/lib64/qt5/bin/qmake +``` + +(The exact `qmake` path can be found with `rpm -ql qt5-base-devel | grep '/qmake$'`.) + +## Build + +Build both applications: + +```sh +cargo build +``` + +Build or run one interface: + +```sh +cargo build -p flasher-gtk +cargo build -p flasher-qt + +cargo run -p flasher-gtk +cargo run -p flasher-qt +``` diff --git a/crates/flasher-gtk/Cargo.toml b/crates/flasher-gtk/Cargo.toml new file mode 100644 index 0000000..c7d8c5b --- /dev/null +++ b/crates/flasher-gtk/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "flasher-gtk" +version = "0.1.0" +edition = "2024" +description = "An Adwaita GTK interface for preparing ALT Linux SBC images" +license = "GPL-2.0-or-later" + +[dependencies] +adw = { package = "libadwaita", version = "0.8", features = ["v1_5"] } +gtk = { package = "gtk4", version = "0.10", features = ["v4_10"] } diff --git a/crates/flasher-gtk/src/main.rs b/crates/flasher-gtk/src/main.rs new file mode 100644 index 0000000..462b498 --- /dev/null +++ b/crates/flasher-gtk/src/main.rs @@ -0,0 +1,558 @@ +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) + } +} diff --git a/crates/flasher-qt/Cargo.toml b/crates/flasher-qt/Cargo.toml new file mode 100644 index 0000000..5bbf918 --- /dev/null +++ b/crates/flasher-qt/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "flasher-qt" +version = "0.1.0" +edition = "2024" +description = "A Qt interface for preparing ALT Linux SBC images" +license = "GPL-2.0-or-later" + +[dependencies] +qmetaobject = "0.2.10" diff --git a/crates/flasher-qt/qml/Main.qml b/crates/flasher-qt/qml/Main.qml new file mode 100644 index 0000000..a41064d --- /dev/null +++ b/crates/flasher-qt/qml/Main.qml @@ -0,0 +1,394 @@ +import QtQuick 2.12 +import QtQuick.Controls 2.12 +import QtQuick.Layouts 1.12 +import Qt.labs.platform 1.1 as Platform + +ApplicationWindow { + id: window + visible: true + width: 760 + height: 820 + minimumWidth: 620 + minimumHeight: 620 + title: qsTr("ALT Image Writer — Qt") + + property bool sourceIsImage: false + property bool outputIsFile: false + property string sourcePath: "" + property string outputPath: "" + property string selectedBoard: "" + property string selectedPlatform: "" + property var driveNames: availableDrivesText.length ? availableDrivesText.split("\n") : [] + + function localPath(url) { + var value = url.toString() + return value.indexOf("file://") === 0 ? decodeURIComponent(value.substring(7)) : value + } + + function fileName(path) { + var parts = path.split("/") + return parts[parts.length - 1] + } + + function ready() { + return sourcePath.length > 0 && selectedBoard.length > 0 + && (outputIsFile ? outputPath.length > 0 : driveBox.currentIndex >= 0) + } + + header: ToolBar { + RowLayout { + anchors.fill: parent + anchors.leftMargin: 16 + anchors.rightMargin: 8 + Label { + text: qsTr("ALT Image Writer") + font.pixelSize: 18 + font.bold: true + Layout.fillWidth: true + } + ToolButton { + text: "ⓘ" + font.pixelSize: 18 + Accessible.name: qsTr("About") + onClicked: aboutDialog.open() + } + } + } + + ScrollView { + id: scroll + anchors.fill: parent + contentWidth: availableWidth + clip: true + + ColumnLayout { + width: Math.min(scroll.availableWidth - 40, 700) + x: Math.max(20, (scroll.availableWidth - width) / 2) + spacing: 16 + + Item { Layout.preferredHeight: 8 } + Label { + text: qsTr("Create Installation Media") + font.pixelSize: 26 + font.bold: true + Layout.alignment: Qt.AlignHCenter + } + Label { + text: qsTr("Prepare an ALT Linux root filesystem or disk image for your board.") + color: palette.mid + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + Layout.fillWidth: true + } + + Frame { + Layout.fillWidth: true + ColumnLayout { + anchors.fill: parent + spacing: 12 + Label { text: qsTr("Installation Source"); font.bold: true; font.pixelSize: 17 } + Label { text: qsTr("Choose a root filesystem archive or a ready-made disk image."); color: palette.mid } + ButtonGroup { id: sourceGroup } + RowLayout { + Label { text: qsTr("Source type"); Layout.fillWidth: true } + RadioButton { + text: qsTr("Root filesystem") + checked: true + ButtonGroup.group: sourceGroup + onCheckedChanged: if (checked) sourceIsImage = false + } + RadioButton { + text: qsTr("Disk image") + ButtonGroup.group: sourceGroup + onCheckedChanged: if (checked) { + sourceIsImage = true + outputIsFile = false + driveOutput.checked = true + } + } + } + RowLayout { + spacing: 12 + Label { + text: sourcePath.length ? fileName(sourcePath) : qsTr("No source selected") + font.bold: sourcePath.length > 0 + elide: Text.ElideMiddle + Layout.fillWidth: true + } + Button { text: qsTr("Choose…"); onClicked: sourceDialog.open() } + } + Label { + text: sourcePath.length ? sourcePath + : sourceIsImage ? qsTr("Supported: .img, .img.xz") + : qsTr("Supported: .tar, .tar.gz, .tar.xz") + color: palette.mid + elide: Text.ElideMiddle + Layout.fillWidth: true + } + } + } + + Frame { + Layout.fillWidth: true + ColumnLayout { + anchors.fill: parent + spacing: 10 + Label { text: qsTr("Target Hardware"); font.bold: true; font.pixelSize: 17 } + Label { text: qsTr("Boards are grouped by platform, SoC family, and architecture."); color: palette.mid } + RowLayout { + ColumnLayout { + Layout.fillWidth: true + Label { + text: selectedBoard.length ? selectedBoard : qsTr("No board selected") + font.bold: selectedBoard.length > 0 + } + Label { + text: selectedBoard.length ? selectedPlatform : qsTr("Choose from the supported board list") + color: palette.mid + } + } + Button { text: qsTr("Select Board…"); onClicked: boardDialog.open() } + } + } + } + + Frame { + Layout.fillWidth: true + ColumnLayout { + anchors.fill: parent + spacing: 12 + Label { text: qsTr("Destination"); font.bold: true; font.pixelSize: 17 } + Label { text: qsTr("Write to removable media or create an image file."); color: palette.mid } + ButtonGroup { id: outputGroup } + RowLayout { + Label { text: qsTr("Output type"); Layout.fillWidth: true } + RadioButton { + id: driveOutput + text: qsTr("Drive") + checked: true + ButtonGroup.group: outputGroup + onCheckedChanged: if (checked) outputIsFile = false + } + RadioButton { + text: qsTr("Image file") + enabled: !sourceIsImage + ButtonGroup.group: outputGroup + onCheckedChanged: if (checked) outputIsFile = true + } + } + RowLayout { + visible: !outputIsFile + Label { text: qsTr("Storage device"); Layout.preferredWidth: 150 } + ComboBox { + id: driveBox + Layout.fillWidth: true + model: driveNames + displayText: currentIndex < 0 ? qsTr("No removable drives detected") : currentText + } + } + RowLayout { + visible: outputIsFile + Label { + text: outputPath.length ? outputPath : qsTr("Choose where to save the image") + elide: Text.ElideMiddle + Layout.fillWidth: true + } + Button { text: qsTr("Choose…"); onClicked: outputDialog.open() } + } + } + } + + Frame { + Layout.fillWidth: true + ColumnLayout { + anchors.fill: parent + spacing: 8 + Label { text: qsTr("Options"); font.bold: true; font.pixelSize: 17 } + RowLayout { + Label { text: qsTr("Root filesystem"); Layout.fillWidth: true } + ComboBox { model: ["ext4", "f2fs"] } + } + CheckBox { text: qsTr("Resize root partition to fill the destination"); checked: true } + CheckBox { text: qsTr("Create a separate 512 MiB boot partition") } + CheckBox { text: qsTr("Encrypt the root partition with LUKS") } + CheckBox { text: qsTr("Add the system serial console") } + RowLayout { + Label { text: qsTr("EFI system partition"); Layout.fillWidth: true } + ComboBox { model: [qsTr("None"), qsTr("EFI (GPT)"), qsTr("EFI (MBR)")] } + } + RowLayout { + Label { text: qsTr("First-boot VNC"); Layout.fillWidth: true } + ComboBox { model: [qsTr("Use image default"), qsTr("Enabled"), qsTr("Disabled")] } + } + } + } + Item { Layout.preferredHeight: 80 } + } + } + + footer: ToolBar { + RowLayout { + anchors.fill: parent + anchors.margins: 10 + Label { text: qsTr("Nothing will be written until you confirm."); color: palette.mid; Layout.fillWidth: true } + Button { + text: qsTr("Review and Write") + highlighted: true + enabled: ready() + onClicked: reviewDialog.open() + } + } + } + + Dialog { + id: boardDialog + parent: Overlay.overlay + anchors.centerIn: parent + width: Math.min(window.width - 48, 620) + height: Math.min(window.height - 80, 650) + modal: true + title: qsTr("Select Target Board") + standardButtons: Dialog.Cancel + + ListModel { id: allBoards } + ListModel { id: filteredBoards } + + function populate() { + if (allBoards.count > 0) return + var platform = "Other" + var lines = supportedBoardsText.split("\n") + for (var i = 0; i < lines.length; ++i) { + var line = lines[i].trim() + if (!line.length) continue + if (line.charAt(line.length - 1) === ":") { + platform = line.substring(0, line.length - 1) + } else { + var names = line.split(/\s+/) + for (var j = 0; j < names.length; ++j) + allBoards.append({ boardName: names[j], platformName: platform }) + } + } + filter("") + } + + function filter(query) { + filteredBoards.clear() + var needle = query.toLowerCase() + for (var i = 0; i < allBoards.count; ++i) { + var item = allBoards.get(i) + if (!needle.length || item.boardName.toLowerCase().indexOf(needle) >= 0 + || item.platformName.toLowerCase().indexOf(needle) >= 0) + filteredBoards.append({ boardName: item.boardName, platformName: item.platformName }) + } + } + + onOpened: { populate(); searchField.forceActiveFocus() } + + ColumnLayout { + anchors.fill: parent + TextField { + id: searchField + Layout.fillWidth: true + placeholderText: qsTr("Search boards or platforms…") + selectByMouse: true + onTextChanged: boardDialog.filter(text) + } + Label { + text: qsTr("%1 matching boards").arg(filteredBoards.count) + color: palette.mid + } + ListView { + id: boardList + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: filteredBoards + spacing: 2 + section.property: "platformName" + section.criteria: ViewSection.FullString + section.delegate: Rectangle { + width: boardList.width + height: 42 + color: window.palette.alternateBase + Label { + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: section + font.bold: true + color: window.palette.highlight + } + } + delegate: ItemDelegate { + width: boardList.width + text: boardName + onClicked: { + selectedBoard = boardName + selectedPlatform = platformName + boardDialog.close() + } + } + ScrollBar.vertical: ScrollBar {} + } + } + } + + Dialog { + id: reviewDialog + parent: Overlay.overlay + anchors.centerIn: parent + modal: true + title: qsTr("Ready to write?") + standardButtons: Dialog.Cancel | Dialog.Ok + onAccepted: previewNotice.open() + ColumnLayout { + Label { text: qsTr("Board"); font.bold: true } + Label { text: selectedBoard + " — " + selectedPlatform } + Label { text: qsTr("Destination"); font.bold: true } + Label { text: outputIsFile ? outputPath : driveBox.currentText; elide: Text.ElideMiddle; Layout.maximumWidth: 450 } + Label { text: qsTr("This is an interface preview. No data will be written."); color: palette.mid } + } + } + + Dialog { + id: aboutDialog + parent: Overlay.overlay + anchors.centerIn: parent + modal: true + title: qsTr("About ALT Image Writer") + standardButtons: Dialog.Ok + Label { + text: qsTr("ALT Image Writer — Qt\nVersion 0.1.0\n\nPrepare ALT Linux installation media for supported SBCs.") + horizontalAlignment: Text.AlignHCenter + } + } + + Popup { + id: previewNotice + parent: Overlay.overlay + x: (parent.width - width) / 2 + y: parent.height - height - 24 + padding: 16 + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + Label { text: qsTr("Flashing is not implemented in this preview") } + Timer { interval: 3500; running: previewNotice.visible; onTriggered: previewNotice.close() } + } + + Platform.FileDialog { + id: sourceDialog + title: qsTr("Choose Installation Source") + fileMode: Platform.FileDialog.OpenFile + nameFilters: sourceIsImage ? [qsTr("Disk images (*.img *.img.xz)"), qsTr("All files (*)")] + : [qsTr("Root filesystem archives (*.tar *.tar.gz *.tar.xz)"), qsTr("All files (*)")] + onAccepted: sourcePath = localPath(file) + } + + Platform.FileDialog { + id: outputDialog + title: qsTr("Create Disk Image") + fileMode: Platform.FileDialog.SaveFile + defaultSuffix: "img" + nameFilters: [qsTr("Raw disk images (*.img)"), qsTr("All files (*)")] + onAccepted: outputPath = localPath(file) + } +} diff --git a/crates/flasher-qt/src/main.rs b/crates/flasher-qt/src/main.rs new file mode 100644 index 0000000..f131bc5 --- /dev/null +++ b/crates/flasher-qt/src/main.rs @@ -0,0 +1,69 @@ +use qmetaobject::{QmlEngine, QString, QVariant}; +use std::fs; + +const BOARDS: &str = include_str!("../../../alt-rootfs-installer/SUPPORTED-BOARDS"); +const QML: &str = include_str!("../qml/Main.qml"); + +fn main() { + let mut engine = QmlEngine::new(); + engine.set_property( + QString::from("supportedBoardsText"), + QVariant::from(QString::from(BOARDS)), + ); + engine.set_property( + QString::from("availableDrivesText"), + QVariant::from(QString::from(available_drives().join("\n"))), + ); + engine.load_data(QML.into()); + engine.exec(); +} + +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 model = fs::read_to_string(entry.path().join("device/model")) + .unwrap_or_default() + .trim() + .to_owned(); + let description = if model.is_empty() { + format!("/dev/{name}") + } else { + model + }; + drives.push(format!( + "{description} · {} · /dev/{name}", + format_size(sectors.saturating_mul(512)) + )); + } + 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) + } +} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index b82def3..0000000 --- a/src/main.rs +++ /dev/null @@ -1,558 +0,0 @@ -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) - } -} -- cgit v1.2.3