Move fast, break things

Data structures and logic was changing very fast.  Don't have the time
to break downt the individual sections ATM.  Sorry future reader (me).
This commit is contained in:
2Shirt 2024-11-03 17:48:52 -08:00
parent 31de043b71
commit 7d4f28f950
Signed by: 2Shirt
GPG key ID: 152FAC923B0E132C
18 changed files with 1331 additions and 72 deletions

View file

@ -1,7 +1,6 @@
{
"keybindings": {
"Home": {
"<b>": "PrevScreen",
"ScanDisks": {
"<Enter>": "Process",
"<q>": "Quit", // Quit the application
"<Ctrl-d>": "Quit", // Another way to quit
@ -9,14 +8,26 @@
"<Ctrl-z>": "Suspend" // Suspend the application
},
"SelectDisks": {
"<b>": "PrevScreen",
"<r>": "ScanDisks",
"<Enter>": "Process",
"<Up>": "KeyUp",
"<Down>": "KeyDown",
"<q>": "Quit", // Quit the application
"<Ctrl-d>": "Quit", // Another way to quit
"<Ctrl-c>": "Quit", // Yet another way to quit
"<Ctrl-z>": "Suspend" // Suspend the application
},
"SelectParts": {
"<b>": "PrevScreen",
"<Enter>": "Process",
"<Up>": "KeyUp",
"<Down>": "KeyDown",
"<q>": "Quit", // Quit the application
"<Ctrl-d>": "Quit", // Another way to quit
"<Ctrl-c>": "Quit", // Yet another way to quit
"<Ctrl-z>": "Suspend" // Suspend the application
},
"Confirm": {
"<b>": "PrevScreen",
"<Enter>": "Process",
"<q>": "Quit", // Quit the application
@ -29,7 +40,6 @@
"<q>": "Quit", // Quit the application
"<Ctrl-d>": "Quit", // Another way to quit
"<Ctrl-c>": "Quit", // Yet another way to quit
"<Ctrl-z>": "Suspend" // Suspend the application
},
}
}

18
Cargo.lock generated
View file

@ -527,19 +527,24 @@ dependencies = [
"json5",
"lazy_static",
"libc",
"once_cell",
"pretty_assertions",
"ratatui",
"raw-cpuid",
"regex",
"serde",
"serde_json",
"signal-hook",
"strip-ansi-escapes",
"strum",
"tempfile",
"tokio",
"tokio-util",
"tracing",
"tracing-error",
"tracing-subscriber",
"vergen-gix",
"walkdir",
]
[[package]]
@ -1881,6 +1886,15 @@ dependencies = [
"unicode-width 0.1.14",
]
[[package]]
name = "raw-cpuid"
version = "11.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ab240315c661615f2ee9f0f2cd32d5a7343a84d5ebcccb99d46e6637565e7b0"
dependencies = [
"bitflags",
]
[[package]]
name = "redox_syscall"
version = "0.5.7"
@ -1903,9 +1917,9 @@ dependencies = [
[[package]]
name = "regex"
version = "1.11.0"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38200e5ee88914975b69f657f0801b6f6dccafd44fd9326302a4aaeecfacb1d8"
checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
dependencies = [
"aho-corasick",
"memchr",

View file

@ -43,18 +43,23 @@ human-panic = "2.0.1"
json5 = "0.4.1"
lazy_static = "1.5.0"
libc = "0.2.158"
once_cell = "1.20.2"
pretty_assertions = "1.4.0"
ratatui = { version = "0.28.1", features = ["serde", "macros"] }
raw-cpuid = "11.2.0"
regex = "1.11.1"
serde = { version = "1.0.208", features = ["derive"] }
serde_json = "1.0.125"
signal-hook = "0.3.17"
strip-ansi-escapes = "0.2.0"
strum = { version = "0.26.3", features = ["derive"] }
tempfile = "3.13.0"
tokio = { version = "1.39.3", features = ["full"] }
tokio-util = "0.7.11"
tracing = "0.1.40"
tracing-error = "0.2.0"
tracing-subscriber = { version = "0.3.18", features = ["env-filter", "serde"] }
walkdir = "2.5.0"
[build-dependencies]
anyhow = "1.0.86"

View file

@ -16,7 +16,7 @@
use serde::{Deserialize, Serialize};
use strum::Display;
use crate::app::Mode;
use crate::{app::Mode, system::disk::Disk};
#[derive(Debug, Clone, PartialEq, Eq, Display, Serialize, Deserialize)]
pub enum Action {
@ -27,11 +27,17 @@ pub enum Action {
Resume,
Quit,
ClearScreen,
DismissPopup,
DisplayPopup(String),
Error(String),
Help,
KeyUp,
KeyDown,
SetMode(Mode),
PrevScreen,
NextScreen,
Process,
SelectDisks(Option<u16>, Option<u16>), // indicies for (source, dest)
SelectParts(Option<u16>, Option<u16>), // indicies for (boot, os)
ScanDisks,
Select(Option<usize>, Option<usize>), // indicies for (source, dest) or (boot, os)
UpdateDiskList(Vec<Disk>),
}

View file

@ -13,7 +13,7 @@
// You should have received a copy of the GNU General Public License
// along with Deja-vu. If not, see <https://www.gnu.org/licenses/>.
//
use std::{collections::HashMap, iter::zip};
use std::iter::zip;
use color_eyre::Result;
use crossterm::event::KeyEvent;
@ -40,10 +40,11 @@ pub struct App {
tick_rate: f64,
frame_rate: f64,
components: Vec<Box<dyn Component>>,
component_indices: HashMap<String, u64>,
should_quit: bool,
should_suspend: bool,
mode: Mode,
cur_mode: Mode,
prev_mode: Mode,
selections: Vec<Option<usize>>,
last_tick_key_events: Vec<KeyEvent>,
action_tx: mpsc::UnboundedSender<Action>,
action_rx: mpsc::UnboundedReceiver<Action>,
@ -52,32 +53,13 @@ pub struct App {
#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Mode {
#[default]
Home,
ScanDisks,
SelectDisks,
SelectParts,
Confirm,
Done,
}
impl Mode {
pub fn next_screen(cur_mode: Mode) -> Mode {
match cur_mode {
Mode::Home => Mode::SelectDisks,
Mode::SelectDisks => Mode::SelectParts,
Mode::SelectParts => Mode::Done,
Mode::Done => Mode::Done,
}
}
pub fn prev_screen(cur_mode: Mode) -> Mode {
match cur_mode {
Mode::Home => Mode::Home,
Mode::SelectDisks => Mode::Home,
Mode::SelectParts => Mode::SelectDisks,
Mode::Done => Mode::SelectParts,
}
}
}
impl App {
pub fn new(tick_rate: f64, frame_rate: f64) -> Result<Self> {
let (action_tx, action_rx) = mpsc::unbounded_channel();
@ -92,24 +74,45 @@ impl App {
Box::new(Footer::new()),
Box::new(Popup::new()),
],
component_indices: HashMap::from([
(String::from("title"), 0),
(String::from("fps"), 1),
(String::from("left"), 2),
(String::from("right"), 3),
(String::from("footer"), 4),
(String::from("popup"), 5),
]),
should_quit: false,
should_suspend: false,
config: Config::new()?,
mode: Mode::Home,
cur_mode: Mode::ScanDisks,
prev_mode: Mode::ScanDisks,
selections: vec![None, None],
last_tick_key_events: Vec::new(),
action_tx,
action_rx,
})
}
pub fn next_mode(&mut self) -> Option<Mode> {
let new_mode = match self.cur_mode {
Mode::ScanDisks => Mode::SelectDisks,
Mode::SelectDisks | Mode::SelectParts => {
if self.selections[1].is_some() {
Mode::Confirm
} else {
self.cur_mode
}
}
Mode::Done => Mode::Done,
Mode::Confirm => match self.prev_mode {
Mode::SelectDisks => Mode::SelectParts,
Mode::SelectParts => Mode::Done,
_ => self.cur_mode,
},
};
if new_mode != self.cur_mode {
if self.cur_mode != Mode::Confirm {
self.prev_mode = self.cur_mode;
}
Some(new_mode)
} else {
None
}
}
pub async fn run(&mut self) -> Result<()> {
let mut tui = Tui::new()?
// .mouse(true) // uncomment this line to enable mouse support
@ -169,7 +172,7 @@ impl App {
fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> {
let action_tx = self.action_tx.clone();
let Some(keymap) = self.config.keybindings.get(&self.mode) else {
let Some(keymap) = self.config.keybindings.get(&self.cur_mode) else {
return Ok(());
};
match keymap.get(&vec![key]) {
@ -207,13 +210,35 @@ impl App {
Action::ClearScreen => tui.terminal.clear()?,
Action::Resize(w, h) => self.handle_resize(tui, w, h)?,
Action::Render => self.render(tui)?,
Action::PrevScreen => self
.action_tx
.send(Action::SetMode(Mode::prev_screen(self.mode)))?,
Action::Process => self
.action_tx
.send(Action::SetMode(Mode::next_screen(self.mode)))?,
Action::SetMode(new_mode) => self.mode = new_mode,
Action::PrevScreen => {
self.action_tx.send(Action::SetMode(self.prev_mode))?;
self.action_tx.send(Action::Select(None, None))?;
}
Action::NextScreen => {
if let Some(mode) = self.next_mode() {
self.action_tx.send(Action::SetMode(mode))?;
}
}
Action::Select(one, two) => {
self.selections[0] = one;
self.selections[1] = two;
}
Action::SetMode(new_mode) => {
self.cur_mode = new_mode;
match new_mode {
Mode::ScanDisks => {
self.prev_mode = self.cur_mode;
self.action_tx.send(Action::Select(None, None))?;
self.action_tx.send(Action::ScanDisks)?;
}
Mode::Done => {
self.action_tx.send(Action::DisplayPopup(String::from(
"COMPLETE\n\nThank you for using this tool!",
)))?;
}
_ => {}
}
}
_ => {}
}
for component in self.components.iter_mut() {

View file

@ -28,6 +28,7 @@ pub mod fps;
pub mod left;
pub mod popup;
pub mod right;
pub mod state;
pub mod title;
/// `Component` is a trait that represents a visual and interactive element of the user interface.

View file

@ -57,9 +57,13 @@ impl Component for Footer {
}
Action::SetMode(new_mode) => {
self.footer_text = match new_mode {
Mode::ScanDisks => String::from("(Enter) to start / (q) to quit"),
Mode::SelectDisks => String::from(
"(Enter) to select / (b) to go back / (i) to install driver / (q) to quit",
"(Enter) to select / / (i) to install driver / (r) to rescan / (q) to quit",
),
Mode::Confirm => {
String::from("(Enter) to confirm / (b) to go back / (q) to quit")
}
Mode::Done => String::from("(Enter) or (q) to quit"),
_ => String::from("(Enter) to select / (b) to go back / (q) to quit"),
}

View file

@ -18,19 +18,23 @@ use crossterm::event::KeyEvent;
use ratatui::{prelude::*, widgets::*};
use tokio::sync::mpsc::UnboundedSender;
use super::Component;
use crate::{action::Action, app::Mode, config::Config};
use super::{state::StatefulList, Component};
use crate::{action::Action, app::Mode, config::Config, system::disk::Disk};
#[derive(Default)]
pub struct Left {
command_tx: Option<UnboundedSender<Action>>,
config: Config,
title_text: String,
item_list: StatefulList<Disk>,
mode: Mode,
selections: Vec<Option<usize>>,
}
impl Left {
pub fn new() -> Self {
Self {
selections: vec![None, None],
title_text: String::from("Home"),
..Default::default()
}
@ -62,12 +66,92 @@ impl Component for Left {
Action::Render => {
// add any logic here that should run on every render
}
Action::SetMode(new_mode) => match new_mode {
Mode::Home => self.title_text = String::from("Home"),
Mode::SelectDisks => self.title_text = String::from("Select Source Disk"),
Mode::SelectParts => self.title_text = String::from("Select Boot Partition"),
Mode::Done => self.title_text = String::from("Done"),
Action::KeyUp => self.item_list.previous(),
Action::KeyDown => self.item_list.next(),
Action::Process => match self.mode {
Mode::ScanDisks => {
if let Some(command_tx) = self.command_tx.clone() {
command_tx.send(Action::ScanDisks)?;
}
}
Mode::Confirm => {
if let Some(command_tx) = self.command_tx.clone() {
command_tx.send(Action::NextScreen)?;
}
}
Mode::SelectDisks | Mode::SelectParts => {
if let Some(index) = self.item_list.state.selected() {
if let Some(command_tx) = self.command_tx.clone() {
let mut selection_one: Option<usize> = None;
let mut selection_two: Option<usize> = None;
// Get selection(s)
if self.selections[0].is_none() {
// First selection
selection_one = Some(index);
selection_two = None;
} else {
// Second selection
if let Some(source_index) = self.selections[0] {
if index != source_index {
selection_one = self.selections[0];
selection_two = Some(index);
}
}
}
// Send selection(s)
// NOTE: This is needed to keep the app and all components in sync
match self.mode {
Mode::SelectDisks => {
command_tx
.send(Action::Select(selection_one, selection_two))?;
}
Mode::SelectParts => {
command_tx
.send(Action::Select(selection_one, selection_two))?;
}
_ => {}
};
// Advance screen if both selections made
if selection_two.is_some() {
command_tx.send(Action::NextScreen)?;
}
}
}
}
_ => {}
},
Action::Select(None, None) => self.selections = vec![None, None],
Action::Select(Some(index), None) => self.selections[0] = Some(index),
Action::SetMode(new_mode) => {
let prev_mode = self.mode;
self.mode = new_mode;
match new_mode {
Mode::ScanDisks => self.title_text = String::from(""),
Mode::SelectDisks => {
self.selections[0] = None;
self.selections[1] = None;
self.title_text = String::from("Select Source and Destination Disks")
}
Mode::SelectParts => {
self.selections[0] = None;
self.selections[1] = None;
self.title_text = String::from("Select Boot and OS Partitions")
}
Mode::Confirm => match prev_mode {
Mode::SelectDisks => self.title_text = String::from("Confirm Selections"),
Mode::SelectParts => {
self.title_text = String::from("Confirm Selections (Again)")
}
_ => {}
},
Mode::Done => self.title_text = String::from("Done"),
}
}
Action::UpdateDiskList(disks) => self.item_list.set_items(disks),
_ => {}
}
Ok(None)
@ -85,13 +169,61 @@ impl Component for Left {
.block(Block::default().borders(Borders::NONE));
frame.render_widget(title, title_area);
// Body
let paragraph = Paragraph::new("Body text...").block(
Block::default()
.borders(Borders::ALL)
.padding(Padding::new(1, 1, 1, 1)),
);
frame.render_widget(paragraph, body_area);
// Body (scan disks)
if self.item_list.items.is_empty() {
let paragraph = Paragraph::new(String::from("Press Enter to start!")).block(
Block::default()
.borders(Borders::ALL)
.padding(Padding::new(1, 1, 1, 1)),
);
frame.render_widget(paragraph, body_area);
// Bail early
return Ok(());
}
// Body (confirm)
if self.mode == Mode::Confirm {
let paragraph = Paragraph::new(String::from("Are the listed selections correct?"))
.block(
Block::default()
.borders(Borders::ALL)
.padding(Padding::new(1, 1, 1, 1)),
);
frame.render_widget(paragraph, body_area);
// Bail early
return Ok(());
}
// Body (list)
let mut list_items = Vec::<ListItem>::new();
if !self.item_list.items.is_empty() {
for (index, item) in self.item_list.items.iter().enumerate() {
let mut item_style = Style::default();
let mut item_text_tail = "";
if let Some(selection_one) = self.selections[0] {
if selection_one == index {
item_style = Style::new().yellow();
item_text_tail = " ~already selected~";
}
}
list_items.push(
ListItem::new(format!(" {item}\n{item_text_tail}\n\n")).style(item_style),
);
}
}
let list = List::new(list_items)
.block(
Block::default()
.borders(Borders::ALL)
.padding(Padding::new(1, 1, 1, 1)),
)
.highlight_spacing(HighlightSpacing::Always)
.highlight_style(Style::new().green().bold())
.highlight_symbol(" --> ")
.repeat_highlight_symbol(false);
frame.render_stateful_widget(list, body_area, &mut self.item_list.state);
// Done
Ok(())

View file

@ -1,3 +1,4 @@
use clap::command;
// This file is part of Deja-vu.
//
// Deja-vu is free software: you can redistribute it and/or modify it
@ -18,17 +19,27 @@ use ratatui::{prelude::*, widgets::*};
use tokio::sync::mpsc::UnboundedSender;
use super::Component;
use crate::{action::Action, config::Config};
use crate::{
action::Action,
app::Mode,
config::Config,
system::disk::{get_disks, Disk},
};
#[derive(Default)]
pub struct Popup {
command_tx: Option<UnboundedSender<Action>>,
config: Config,
disks: Vec<Disk>,
popup_text: String,
}
impl Popup {
pub fn new() -> Self {
Self::default()
Self {
popup_text: String::from("Scanning Disks..."),
..Default::default()
}
}
}
@ -51,16 +62,35 @@ impl Component for Popup {
Action::Render => {
// add any logic here that should run on every render
}
Action::DismissPopup => self.popup_text.clear(),
Action::DisplayPopup(new_text) => self.popup_text = String::from(new_text),
Action::ScanDisks => {
if let Some(command_tx) = self.command_tx.clone() {
self.disks = get_disks();
command_tx.send(Action::NextScreen)?;
}
}
Action::SetMode(Mode::SelectDisks) => {
self.popup_text.clear();
if let Some(command_tx) = self.command_tx.clone() {
command_tx.send(Action::UpdateDiskList(self.disks.clone()))?;
}
}
_ => {}
}
Ok(None)
}
fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
if self.popup_text.is_empty() {
// Only show popup if popup_text is set
return Ok(());
}
let popup_block = Block::default()
.borders(Borders::ALL)
.style(Style::default().cyan().bold());
let popup = Paragraph::new(vec![Line::from(Span::raw("Popup text..."))])
let popup = Paragraph::new(vec![Line::from(Span::raw(&self.popup_text))])
.block(popup_block)
.centered()
.wrap(Wrap { trim: false });

View file

@ -19,17 +19,24 @@ use ratatui::{prelude::*, widgets::*};
use tokio::sync::mpsc::UnboundedSender;
use super::Component;
use crate::{action::Action, config::Config};
use crate::{action::Action, app::Mode, config::Config};
#[derive(Default)]
pub struct Right {
command_tx: Option<UnboundedSender<Action>>,
config: Config,
cur_mode: Mode,
prev_mode: Mode,
all_modes: Vec<Mode>,
selections: Vec<Option<usize>>,
}
impl Right {
pub fn new() -> Self {
Self::default()
Self {
selections: vec![None, None],
..Default::default()
}
}
}
@ -58,6 +65,17 @@ impl Component for Right {
Action::Render => {
// add any logic here that should run on every render
}
Action::Select(one, two) => {
self.selections[0] = one;
self.selections[1] = two;
}
Action::SetMode(new_mode) => {
if self.cur_mode != Mode::Confirm {
self.prev_mode = self.cur_mode;
}
self.cur_mode = new_mode;
self.all_modes.push(new_mode);
}
_ => {}
}
Ok(None)
@ -76,7 +94,11 @@ impl Component for Right {
frame.render_widget(title, title_area);
// Body
let paragraph = Paragraph::new("Some info...").block(
let paragraph = Paragraph::new(format!(
"Prev Mode: {:?} // Cur Mode: {:?}\n{:?}\n{:?}",
self.prev_mode, self.cur_mode, self.all_modes, self.selections,
))
.block(
Block::default()
.borders(Borders::ALL)
.padding(Padding::new(1, 1, 1, 1)),

View file

@ -13,3 +13,116 @@
// You should have received a copy of the GNU General Public License
// along with Deja-vu. If not, see <https://www.gnu.org/licenses/>.
//
use std::collections::{hash_map, HashMap};
use ratatui::widgets::ListState;
#[derive(Default, Debug, Clone, PartialEq)]
pub struct StatefulList<T> {
pub state: ListState,
pub items: Vec<T>,
pub last_selected: Option<usize>,
pub selected: HashMap<usize, bool>,
}
impl<T: Clone> StatefulList<T> {
#[must_use]
pub fn new() -> StatefulList<T> {
StatefulList {
state: ListState::default(),
items: Vec::new(),
last_selected: None,
selected: HashMap::new(),
}
}
pub fn clear_items(&mut self) {
// Clear list and rebuild with provided items
self.items.clear();
}
#[must_use]
pub fn get_selected(&self) -> Option<&T> {
if let Some(i) = self.state.selected() {
self.items.get(i)
} else {
None
}
}
pub fn pop_selected(&mut self) -> Option<T> {
if let Some(i) = self.state.selected() {
Some(self.items[i].clone())
} else {
None
}
}
fn select_first_item(&mut self) {
if self.items.is_empty() {
self.state.select(None);
} else {
self.state.select(Some(0));
}
self.last_selected = None;
}
pub fn set_items(&mut self, items: Vec<T>) {
// Clear list and rebuild with provided items
self.clear_items();
for item in items {
self.items.push(item);
}
// Reset state and select first item (if available)
self.state = ListState::default();
self.select_first_item();
}
pub fn next(&mut self) {
if self.items.is_empty() {
return;
}
let i = match self.state.selected() {
Some(i) => {
if i >= self.items.len() - 1 {
0
} else {
i + 1
}
}
None => self.last_selected.unwrap_or(0),
};
self.state.select(Some(i));
}
pub fn previous(&mut self) {
if self.items.is_empty() {
return;
}
let i = match self.state.selected() {
Some(i) => {
if i == 0 {
self.items.len() - 1
} else {
i - 1
}
}
None => self.last_selected.unwrap_or(0),
};
self.state.select(Some(i));
}
pub fn toggle_selected(&mut self) {
if let Some(i) = self.state.selected() {
if let hash_map::Entry::Vacant(e) = self.selected.entry(i) {
// Was NOT selected, WILL be shortly
self.last_selected = Some(i);
e.insert(true);
} else {
// WAS selected, will NOT be shortly
self.last_selected = None;
self.selected.remove(&i);
}
}
}
}

View file

@ -520,7 +520,7 @@ mod tests {
let c = Config::new()?;
assert_eq!(
c.keybindings
.get(&Mode::Home)
.get(&Mode::ScanDisks) // i.e. Home
.unwrap()
.get(&parse_key_sequence("<q>").unwrap_or_default())
.unwrap(),

View file

@ -26,6 +26,7 @@ mod components;
mod config;
mod errors;
mod logging;
mod system;
mod tui;
#[tokio::main]

19
src/system.rs Normal file
View file

@ -0,0 +1,19 @@
// This file is part of Deja-vu.
//
// Deja-vu is free software: you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Deja-vu is distributed in the hope that it will be useful, but
// WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
// See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Deja-vu. If not, see <https://www.gnu.org/licenses/>.
//
pub mod cpu;
pub mod disk;
pub mod diskpart;
pub mod drivers;

28
src/system/cpu.rs Normal file
View file

@ -0,0 +1,28 @@
// This file is part of Deja-vu.
//
// Deja-vu is free software: you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Deja-vu is distributed in the hope that it will be useful, but
// WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
// See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Deja-vu. If not, see <https://www.gnu.org/licenses/>.
//
/// CPU Functions
#[must_use]
pub fn get_cpu_name() -> String {
let cpuid = raw_cpuid::CpuId::new();
if let Some(name) = cpuid.get_processor_brand_string() {
String::from(name.as_str())
} else if let Some(vendor) = cpuid.get_vendor_info() {
format!("{vendor}: Unknown Model")
} else {
String::from("Unknown")
}
}

364
src/system/disk.rs Normal file
View file

@ -0,0 +1,364 @@
// This file is part of Deja-vu.
//
// Deja-vu is free software: you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Deja-vu is distributed in the hope that it will be useful, but
// WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
// See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Deja-vu. If not, see <https://www.gnu.org/licenses/>.
//
use serde::{Deserialize, Serialize};
use std::{
fmt,
process::{Command, Stdio},
thread::sleep,
time::Duration,
};
use once_cell::sync::Lazy;
use regex::Regex;
use crate::system::diskpart;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Disk {
pub conn_type: String,
pub id: String,
pub model: String,
pub part_type: PartitionTableType,
pub parts: Vec<Partition>,
pub parts_description: Vec<String>,
pub sector_size: usize,
pub serial: String,
pub size: u64, // In bytes
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Partition {
pub id: String,
pub is_selected: bool,
pub fs_type: Option<String>,
pub label: Option<String>,
pub letter: Option<String>,
pub part_type: String,
pub size: u64, // In bytes
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum PartitionTableType {
#[default]
Guid,
Legacy,
}
impl Disk {
pub fn generate_descriptions(&mut self) {
for part in &self.parts {
self.parts_description.push(format!("{part}"));
}
}
#[must_use]
pub fn get_id(&self) -> &str {
self.id.as_str()
}
}
impl fmt::Display for Disk {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
if cfg!(windows) {
write!(
f,
"Disk {:<3} {:>11} {:<4} {} ({})",
self.id,
bytes_to_string(&self.size),
self.conn_type,
self.model,
self.serial,
)
} else {
write!(
f,
"{:<14} {:>11} {:<4} {} ({})",
self.id,
bytes_to_string(&self.size),
self.conn_type,
self.model,
self.serial,
)
}
}
}
impl fmt::Display for Partition {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let mut s: String;
let fs = if let Some(fs_type) = &self.fs_type {
format!("({fs_type})")
} else {
String::from("(?)")
};
if cfg!(windows) {
s = format!(
"{:<8} {:>11} {:<7}",
self.id,
bytes_to_string(&self.size),
fs
);
} else {
s = format!(
"{:<14} {:>11} {:<7}",
self.id,
bytes_to_string(&self.size),
fs
);
}
if let Some(l) = &self.label {
s = format!("{s} \"{l}\"");
}
write!(f, "{s}")
}
}
impl fmt::Display for PartitionTableType {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match &self {
PartitionTableType::Guid => write!(f, "GPT / UEFI"),
PartitionTableType::Legacy => write!(f, "MBR / Legacy"),
}
}
}
pub fn get_disks() -> Vec<Disk> {
let disks;
if cfg!(windows) {
disks = diskpart::get_disks();
} else {
disks = get_fake_disks();
sleep(Duration::from_secs(2));
}
disks
}
#[allow(clippy::too_many_lines)]
#[must_use]
pub fn get_fake_disks() -> Vec<Disk> {
let mut disks = vec![
Disk {
conn_type: "SATA".to_string(),
id: "/dev/sda".to_string(),
model: "Samsung Evo 870".to_string(),
part_type: PartitionTableType::Legacy,
parts: vec![
Partition {
id: String::from("1"),
fs_type: Some(String::from("NTFS")),
label: Some(String::from("System Reserved")),
part_type: String::from("7"),
size: 104_857_600,
..Default::default()
},
Partition {
id: String::from("2"),
fs_type: None,
label: None,
part_type: String::from("5"),
size: 267_806_310_400,
..Default::default()
},
Partition {
id: String::from("5"),
fs_type: Some(String::from("NTFS")),
label: Some(String::from("Win7")),
part_type: String::from("7"),
size: 267_701_452_800,
..Default::default()
},
Partition {
id: String::from("6"),
fs_type: Some(String::from("NTFS")),
label: Some(String::from("Tools")),
part_type: String::from("7"),
size: 524_288_000,
..Default::default()
},
],
serial: "MDZ1243".to_string(),
size: 268_435_456_000,
..Default::default()
},
Disk {
conn_type: "SATA".to_string(),
id: "Disk 2".to_string(),
model: "ADATA Garbage".to_string(),
part_type: PartitionTableType::Legacy,
parts: vec![Partition {
id: String::from("1"),
fs_type: Some(String::from("NTFS")),
label: Some(String::from("Scratch")),
part_type: String::from("7"),
size: 249_998_951_424,
..Default::default()
}],
serial: "000010000".to_string(),
size: 250_000_000_000,
..Default::default()
},
Disk {
conn_type: "NVMe".to_string(),
id: "/dev/nvme0n1".to_string(),
model: "Crucial P3 Plus".to_string(),
part_type: PartitionTableType::Guid,
parts: vec![
Partition {
id: String::from("1"),
fs_type: Some(String::from("FAT32")),
label: Some(String::from("ESP")),
part_type: String::from("EFI"),
size: 272_629_760,
..Default::default()
},
Partition {
id: String::from("2"),
fs_type: None,
label: None,
part_type: String::from("MSR"),
size: 16_777_216,
..Default::default()
},
Partition {
id: String::from("3"),
fs_type: Some(String::from("NTFS")),
label: Some(String::from("Win10")),
part_type: String::from("MS Basic Data"),
size: 824_340_119_552,
..Default::default()
},
],
serial: "80085".to_string(),
size: 268_435_456_000,
..Default::default()
},
Disk {
conn_type: "IDE".to_string(),
id: "/dev/hda".to_string(),
model: "Fireball".to_string(),
part_type: PartitionTableType::Guid,
parts: vec![
Partition {
id: String::from("1"),
fs_type: Some(String::from("FAT32")),
label: Some(String::from("EFI Boot")),
part_type: String::from("EFI"),
size: 209_715_200,
..Default::default()
},
Partition {
id: String::from("2"),
fs_type: None,
label: None,
part_type: String::from("{48465300-0000-11AA-AA11-00306543ECAC}"),
size: 171_586_879_488,
..Default::default()
},
],
serial: "0xfff".to_string(),
size: 171_798_691_840,
..Default::default()
},
Disk {
conn_type: "MISC".to_string(),
id: "A:\\".to_string(),
part_type: PartitionTableType::Legacy,
parts: Vec::new(),
model: "Iomega".to_string(),
serial: "000".to_string(),
size: 14,
..Default::default()
},
Disk::default(),
];
for disk in &mut disks {
disk.generate_descriptions();
}
disks
}
#[must_use]
pub fn get_disk_serial_number(id: &str) -> String {
let mut serial = String::new();
if cfg!(windows) {
let output = Command::new("wmic")
.args([
"diskdrive",
"where",
format!("index='{id}'").as_str(),
"get",
"serialNumber",
"/value",
])
.stdout(Stdio::piped())
.output();
if let Ok(result) = output {
let s = String::from_utf8_lossy(&result.stdout).trim().to_string();
if s.len() >= 15 {
serial = String::from(&s[14..]);
}
}
}
serial
}
/// Misc
///
/// Clippy exception is fine because this supports sizes up to 2 EiB
#[allow(clippy::cast_precision_loss)]
#[must_use]
pub fn bytes_to_string(size: &u64) -> String {
let units = "KMGTPEZY".chars();
let scale = 1024.0;
let mut size = *size as f64;
let mut suffix: Option<char> = None;
for u in units {
if size < scale {
break;
}
size /= scale;
suffix = Some(u);
}
if let Some(s) = suffix {
format!("{size:4.2} {s}iB")
} else {
format!("{size:4.2} B")
}
}
/// # Panics
///
/// Will panic if s is not simliar to 32B, 64MB, etc...
pub fn string_to_bytes(s: &str) -> u64 {
static RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(\d+)\s+(\w+)B").unwrap());
let base: u64 = 1024;
let mut size: u64 = 0;
for (_, [size_str, suffix]) in RE.captures_iter(s).map(|c| c.extract()) {
let x: u64 = size_str.parse().unwrap();
size += x;
match suffix {
"K" => size *= base,
"M" => size *= base.pow(2),
"G" => size *= base.pow(3),
"T" => size *= base.pow(4),
"P" => size *= base.pow(5),
"E" => size *= base.pow(6),
"Z" => size *= base.pow(7),
"Y" => size *= base.pow(8),
_ => (),
}
}
size
}

382
src/system/diskpart.rs Normal file
View file

@ -0,0 +1,382 @@
// This file is part of Deja-vu.
//
// Deja-vu is free software: you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Deja-vu is distributed in the hope that it will be useful, but
// WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
// See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Deja-vu. If not, see <https://www.gnu.org/licenses/>.
//
use std::{
collections::HashMap,
fs::File,
io::Write,
process::{Command, Output, Stdio},
};
use once_cell::sync::Lazy;
use regex::Regex;
use tempfile::tempdir;
use tracing::{info, warn};
use crate::system::disk::{
get_disk_serial_number, string_to_bytes, Disk, Partition, PartitionTableType,
};
static DEFAULT_MAX_DISKS: usize = 8;
pub fn add_disk_details(disk: &mut Disk, disk_details: Option<&str>) {
static RE_DETAILS: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(.*?)\r?\nDisk ID\s*:\s+(.*?)\r?\nType\s*:\s+(.*?)\r?\n").unwrap()
});
static RE_UUID: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^\{[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}\}$").unwrap()
});
// Get details
let details: String;
if let Some(details_str) = disk_details {
details = String::from(details_str);
} else {
let script = format!("select disk {}\r\ndetail disk", disk.id);
details = run_script(&script);
};
// Parse details
for (_, [model, part_type, conn_type]) in
RE_DETAILS.captures_iter(&details).map(|c| c.extract())
{
disk.model = String::from(model);
disk.conn_type = String::from(conn_type);
if RE_UUID.is_match(part_type) {
disk.part_type = PartitionTableType::Guid;
} else {
disk.part_type = PartitionTableType::Legacy;
}
disk.serial = get_disk_serial_number(&disk.id);
}
}
pub fn add_partition_details(
disk: &mut Disk,
disk_details: Option<&str>,
part_details: Option<&str>,
) {
// TODO: Convert to get_partition_details(disk_id: &str, disk_details [..]){}
// Drops need to have mutable access to disk
static RE_LIS: Lazy<Regex> =
Lazy::new(|| Regex::new(r"Partition\s+(\d+)\s+\w+\s+(\d+\s+\w+B)").unwrap());
// List partition
let contents: String;
if let Some(details) = disk_details {
contents = String::from(details);
} else {
let script = format!("select disk {}\r\nlist partition", disk.id);
contents = run_script(&script);
};
for (_, [number, size]) in RE_LIS.captures_iter(&contents).map(|c| c.extract()) {
let part = Partition {
id: String::from(number),
size: string_to_bytes(size),
..Default::default()
};
// pub fs_type: Option<String>,
// pub label: Option<String>,
// pub part_type: String,
disk.parts.push(part);
}
// Detail parititon
let mut script = vec![format!("select disk {}", disk.id)];
for part in &disk.parts {
if part_details.is_some() {
// Currently only used by tests
break;
}
script.push(format!(
// Remove/Assign included to ensure all (accessible) volumes have letters
"\r\nselect partition {}\r\nremove noerr\r\nassign noerr\r\ndetail partition",
part.id
));
}
let part_contents: String;
if let Some(details) = part_details {
part_contents = String::from(details);
} else {
part_contents = run_script(script.join("\r\n").as_str());
};
parse_partition_details(disk, &part_contents);
}
#[must_use]
pub fn build_dest_format_script(disk_id: &str, part_type: &PartitionTableType) -> String {
let mut script = vec!["select disk {disk_id}", "clean"];
match part_type {
PartitionTableType::Guid => {
script.push("convert gpt");
script.push("create partition efi size=260");
script.push("format fs=fat32 quick label=ESP");
script.push("create partition msr size=16");
}
PartitionTableType::Legacy => {
script.push("create partition primary size=100");
script.push("active");
script.push("format fs=ntfs quick label=System");
}
}
script.join("\r\n").replace("{disk_id}", disk_id)
}
#[must_use]
pub fn build_get_disk_script(disk_nums: Option<Vec<&str>>) -> String {
let capacity = DEFAULT_MAX_DISKS * 3 + 1;
let script: String;
// Get disk and partition details
if let Some(disks) = disk_nums {
// (Slower case)
let mut script_parts = Vec::with_capacity(capacity);
// Get list of disks
script_parts.push(String::from("list disk"));
// Add disks from provided list
for num in disks {
script_parts.push(format!("select disk {num}"));
script_parts.push(String::from("detail disk"));
script_parts.push(String::from("list partition"));
}
// Done
script = script_parts.join("\n");
} else {
// (Best case)
let mut script_parts = Vec::with_capacity(capacity);
// Get list of disks
script_parts.push("list disk");
// Assuming first disk number is zero
script_parts.push("select disk 0");
script_parts.push("detail disk");
script_parts.push("list partition");
// Limit to 8 disks (if there's more the manual "worst" case will be used)
let mut i = 0;
while i < 8 {
script_parts.push("select disk next");
script_parts.push("detail disk");
script_parts.push("list partition");
i += 1;
}
// Done
script = script_parts.join("\n");
}
script
}
pub fn get_disks() -> Vec<Disk> {
static RE_DIS_DET: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?s)Disk (\d+) is now the selected disk.*?\r?\n\s*\r?\n(.*)").unwrap()
});
static RE_DIS_LIS: Lazy<Regex> =
Lazy::new(|| Regex::new(r"Disk\s+(\d+)\s+(\w+)\s+(\d+\s+\w+B)").unwrap());
let mut contents: String;
let mut output;
let mut script: String;
// Run diskpart and check result
script = build_get_disk_script(None);
output = run_script_raw(&script);
contents = String::from_utf8_lossy(&output.stdout).to_string();
if let Some(return_code) = output.status.code() {
let disk_nums = parse_disk_numbers(&contents);
if return_code != 0 && !disk_nums.is_empty() {
// The base assumptions were correct! skipping fallback method
//
// Since the return_code was not zero, and at least one disk was detected, that
// means that the number of disks was less than DEFAULT_MAX_DISKS
//
// If the number of disks is exactly DEFAULT_MAX_DISKS then we may be omitting some
// disk(s) from the list so a manual check is needed
// This only adds one additional diskpart call, but avoiding it is preferred
//
// If zero disks were detected then either the first disk number wasn't zero or no
// disks were actually connected
// (this could be due to missing drivers but we're leaving that issue to the user)
info!("Disk assumptions correct!");
} else {
// The base assumptions were wrong so we need to check which disk numbers are present
warn!("Disk assumptions incorrect, falling back to manual method.");
script = build_get_disk_script(Some(disk_nums));
output = run_script_raw(&script);
contents = String::from_utf8_lossy(&output.stdout).to_string();
}
}
// Split Diskpart output contents into sections
let mut dp_sections = split_diskpart_disk_output(&contents);
// Build Disk structs
// NOTE: A HashMap is used because it's possible to have gaps in the list of disks
// i.e. 0, 1, 3, 4
// For instance, this can happen if a drive is disconnected after startup
let mut disks_map: HashMap<&str, Disk> = HashMap::with_capacity(DEFAULT_MAX_DISKS);
for (_, [number, _status, size]) in RE_DIS_LIS
.captures_iter(dp_sections.remove(0)) // This is the "list disk" section
.map(|c| c.extract())
{
disks_map.insert(
number,
Disk {
id: String::from(number),
size: string_to_bytes(size),
..Default::default()
},
);
}
// Add Disk details
let mut disks_raw: Vec<Disk> = Vec::with_capacity(DEFAULT_MAX_DISKS);
for section in dp_sections {
for (_, [id, details]) in RE_DIS_DET.captures_iter(section).map(|c| c.extract()) {
if let Some(mut disk) = disks_map.remove(id) {
// We remove the disk from the HashMap because we're moving it to the Vec
add_disk_details(&mut disk, Some(details));
add_partition_details(&mut disk, Some(details), None);
disk.generate_descriptions();
disks_raw.push(disk);
}
}
}
// Return list of Disks
disks_raw
}
pub fn parse_disk_numbers(contents: &str) -> Vec<&str> {
//Disk 0 is now the selected disk.
//
//Red Hat VirtIO SCSI Disk Device
//Disk ID: {E9CE8DFA-46B2-43C1-99BB-850C661CEE6B}
static RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\s+Disk\s+(\d+).*\n.*\n.*\nDisk ID:").unwrap());
let mut disk_nums = Vec::new();
for (_, [number]) in RE.captures_iter(contents).map(|c| c.extract()) {
disk_nums.push(number);
}
disk_nums
}
pub fn parse_partition_details(disk: &mut Disk, contents: &str) {
// TODO: Update multiple fields at once?
// https://stackoverflow.com/a/52905826
static RE_PAR: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r"Partition (\d+)\r?\nType\s*: (\S+)(\r?\n.*){5}\s*(Volume.*\r?\n.*\r?\n|There is no volume)(.*)",
)
.unwrap()
});
static RE_VOL: Lazy<Regex> = Lazy::new(|| {
// Volume ### Ltr Label Fs Type Size Status Info
// ---------- --- ----------- ----- ---------- ------- --------- --------
// * Volume 1 S ESP FAT32 Partition 100 MB Healthy Hidden
Regex::new(r"..Volume (\d.{2}) (.{3}) (.{11}) (.{5})").unwrap()
});
for (part_index, (_, [_part_id, part_type, _, _vol_header, vol_line])) in RE_PAR
.captures_iter(contents)
.map(|c| c.extract())
.enumerate()
{
let part = &mut disk.parts[part_index];
// Partition info
if !part_type.trim().is_empty() {
part.part_type = String::from(part_type.trim());
}
// Volume info
for (_, [_id, letter, label, fs_type]) in
RE_VOL.captures_iter(vol_line).map(|c| c.extract())
{
if !label.trim().is_empty() {
part.label = Some(String::from(label.trim()));
}
if !letter.trim().is_empty() {
part.letter = Some(String::from(letter.trim()));
}
if !fs_type.trim().is_empty() {
part.fs_type = Some(String::from(fs_type.trim()));
}
}
}
}
pub fn refresh_disk_info(disk: &mut Disk) {
// TODO: Needs refactor - assuming add_ functions are replaced with get_ variants
disk.parts.clear();
disk.parts_description.clear();
add_disk_details(disk, None);
add_partition_details(disk, None, None);
disk.generate_descriptions();
}
/// # Panics
///
/// Will panic if diskpart fails to run the script or if the script if malformed
pub fn run_script_raw(script: &str) -> Output {
info!("Running Diskpart: {:?}", &script);
let temp_dir = tempdir().expect("Failed to create temp dir");
let script_path = temp_dir.path().join("diskpart.script");
let mut script_file = File::create(&script_path).expect("Failed to create temp file");
script_file
.write_all(script.as_bytes())
.expect("Failed to write script to disk");
let output = Command::new("diskpart")
.args(["/s", format!("{}", script_path.display()).as_str()])
.stdout(Stdio::piped())
.output()
.expect("Failed to execute command");
output
}
#[must_use]
pub fn run_script(script: &str) -> String {
let output = run_script_raw(script);
String::from_utf8_lossy(&output.stdout).to_string()
}
pub fn split_diskpart_disk_output(contents: &str) -> Vec<&str> {
// NOTE: A simple split isn't helpful since we want to include the matching lines
static RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"Disk \d+ is now the selected disk").unwrap());
let mut sections = Vec::new();
let mut starts: Vec<usize> = vec![0];
let mut ends: Vec<usize> = Vec::new();
let _: Vec<_> = RE
.find_iter(contents)
.map(|m| {
ends.push(m.start() - 1);
starts.push(m.start());
})
.collect();
ends.push(contents.len());
let ranges: Vec<(&usize, &usize)> = starts.iter().zip(ends.iter()).collect();
for range in ranges {
let start = *range.0;
let end = *range.1;
sections.push(&contents[start..end]);
}
sections
}

103
src/system/drivers.rs Normal file
View file

@ -0,0 +1,103 @@
// This file is part of Deja-vu.
//
// Deja-vu is free software: you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Deja-vu is distributed in the hope that it will be useful, but
// WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
// See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Deja-vu. If not, see <https://www.gnu.org/licenses/>.
//
use std::{env, fmt, fs::read_dir, path::PathBuf, process::Command};
use tracing::info;
use walkdir::WalkDir;
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Driver {
pub name: String,
pub path: PathBuf,
pub inf_paths: Vec<PathBuf>,
}
impl Driver {
fn new() -> Driver {
Driver {
name: String::new(),
path: PathBuf::new(),
inf_paths: Vec::new(),
}
}
}
impl Default for Driver {
fn default() -> Driver {
Driver::new()
}
}
impl fmt::Display for Driver {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.name)
}
}
/// # Panics
///
/// Will panic if a driver fails to load
pub fn load(inf_paths: &Vec<PathBuf>) {
// Load drivers into live environment
for inf in inf_paths {
let inf = inf.clone();
if let Ok(path) = inf.into_os_string().into_string() {
info!("Installing driver: {}", &path);
Command::new("drvload")
.arg(path)
.output()
.expect("Failed to load driver");
}
}
}
pub fn scan() -> Vec<Driver> {
let mut drivers: Vec<Driver> = Vec::new();
if let Ok(exe_path) = env::current_exe() {
let driver_path = exe_path.with_file_name("drivers");
if let Ok(dir_entry) = read_dir(driver_path) {
for entry in dir_entry.flatten() {
if entry.path().is_dir() {
if let Ok(name) = entry.file_name().into_string() {
drivers.push(Driver {
name,
path: entry.path(),
inf_paths: Vec::new(),
});
}
}
}
}
}
drivers.sort();
drivers.reverse();
for driver in &mut drivers {
if &driver.name[..1] == "0" {
driver.name = String::from(&driver.name[1..]);
}
for entry in WalkDir::new(&driver.path)
.into_iter()
.filter_map(Result::ok)
{
if let Some(ext) = entry.path().extension() {
if ext == "inf" {
driver.inf_paths.push(entry.into_path());
}
}
}
}
drivers
}