summaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/flasher-gtk/Cargo.toml10
-rw-r--r--crates/flasher-gtk/src/main.rs558
-rw-r--r--crates/flasher-qt/Cargo.toml9
-rw-r--r--crates/flasher-qt/qml/Main.qml394
-rw-r--r--crates/flasher-qt/src/main.rs69
5 files changed, 1040 insertions, 0 deletions
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<String>,
+ source_is_image: bool,
+ output_file: Option<String>,
+ 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::<Vec<_>>(),
+ );
+ 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::<Vec<_>>(),
+ );
+ 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(
+ &gtk::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: &gtk::Button,
+ state: &Rc<RefCell<State>>,
+ 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<String> {
+ 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::<u64>().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<String> {
+ 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::<u64>().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)
+ }
+}