From 94faae27ace8aa2e2f9952cc88f6ce4f106339a1 Mon Sep 17 00:00:00 2001 From: 2Shirt <2xShirt@gmail.com> Date: Sat, 8 Nov 2025 16:52:09 -0800 Subject: [PATCH] Add initial base for win-installer --- Cargo.lock | 32 +++- Cargo.toml | 4 +- win_installer/Cargo.toml | 48 ++++++ win_installer/build.rs | 28 ++++ win_installer/src/app.rs | 315 +++++++++++++++++++++++++++++++++++++ win_installer/src/main.rs | 34 ++++ win_installer/src/state.rs | 46 ++++++ win_installer/src/wim.rs | 218 +++++++++++++++++++++++++ 8 files changed, 721 insertions(+), 4 deletions(-) create mode 100644 win_installer/Cargo.toml create mode 100644 win_installer/build.rs create mode 100644 win_installer/src/app.rs create mode 100644 win_installer/src/main.rs create mode 100644 win_installer/src/state.rs create mode 100644 win_installer/src/wim.rs diff --git a/Cargo.lock b/Cargo.lock index e156095..ab28626 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2661,9 +2661,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.19.1" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand", "getrandom 0.3.2", @@ -3126,6 +3126,28 @@ dependencies = [ "wit-bindgen-rt", ] +[[package]] +name = "win-installer" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "color-eyre", + "core", + "crossterm", + "futures", + "ratatui", + "serde", + "tempfile", + "tokio", + "toml", + "tracing", + "tracing-error", + "tracing-subscriber", + "vergen-gix", + "xml", +] + [[package]] name = "winapi" version = "0.3.9" @@ -3354,6 +3376,12 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +[[package]] +name = "xml" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "838dd679b10a4180431ce7c2caa6e5585a7c8f63154c19ae99345126572e80cc" + [[package]] name = "yaml-rust2" version = "0.10.0" diff --git a/Cargo.toml b/Cargo.toml index 7dc2384..0a6700c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,6 @@ # along with Deja-Vu. If not, see . [workspace] -members = ["core", "boot_diags", "deja_vu", "pe_menu"] -default-members = ["core", "boot_diags", "deja_vu", "pe_menu"] +members = ["core", "boot_diags", "deja_vu", "pe_menu", "win_installer"] +default-members = ["core", "boot_diags", "deja_vu", "pe_menu", "win_installer"] resolver = "2" diff --git a/win_installer/Cargo.toml b/win_installer/Cargo.toml new file mode 100644 index 0000000..d7bcc76 --- /dev/null +++ b/win_installer/Cargo.toml @@ -0,0 +1,48 @@ +# 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 . + +[package] +name = "win-installer" +authors = ["2Shirt <2xShirt@gmail.com>"] +edition = "2024" +license = "GPL" +version = "0.1.0" + +[dependencies] +core = { path = "../core" } +clap = { version = "4.4.5", features = [ + "derive", + "cargo", + "wrap_help", + "unicode", + "string", + "unstable-styles", + ] } +color-eyre = "0.6.3" +crossterm = { version = "0.28.1", features = ["event-stream"] } +futures = "0.3.30" +ratatui = "0.29.0" +serde = { version = "1.0.217", features = ["derive"] } +tokio = { version = "1.43.0", features = ["full"] } +toml = "0.8.13" +tracing = "0.1.41" +tracing-error = "0.2.0" +tracing-subscriber = { version = "0.3.18", features = ["env-filter", "serde"] } +tempfile = "3.23.0" +xml = "1.1.0" + +[build-dependencies] +anyhow = "1.0.86" +vergen-gix = { version = "1.0.0", features = ["build", "cargo"] } diff --git a/win_installer/build.rs b/win_installer/build.rs new file mode 100644 index 0000000..8988d39 --- /dev/null +++ b/win_installer/build.rs @@ -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 . +// +use anyhow::Result; +use vergen_gix::{BuildBuilder, CargoBuilder, Emitter, GixBuilder}; + +fn main() -> Result<()> { + let build = BuildBuilder::all_build()?; + let gix = GixBuilder::all_git()?; + let cargo = CargoBuilder::all_cargo()?; + Emitter::default() + .add_instructions(&build)? + .add_instructions(&gix)? + .add_instructions(&cargo)? + .emit() +} diff --git a/win_installer/src/app.rs b/win_installer/src/app.rs new file mode 100644 index 0000000..da38f04 --- /dev/null +++ b/win_installer/src/app.rs @@ -0,0 +1,315 @@ +// 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 . +// +use core::{ + action::Action, + components::{Component, footer::Footer, left::Left, popup, right::Right, title::Title}, + config::Config, + state::Mode, + tasks::{TaskType, Tasks}, + tui::{Event, Tui}, +}; +use std::{ + iter::zip, + path::PathBuf, + sync::{Arc, Mutex}, +}; + +use color_eyre::Result; +use ratatui::{ + crossterm::event::KeyEvent, + layout::{Constraint, Direction, Layout}, + prelude::Rect, +}; +use tokio::sync::mpsc; +use tracing::{debug, info}; + +use crate::state::State; + +pub struct App<'a> { + // TUI + action_rx: mpsc::UnboundedReceiver, + action_tx: mpsc::UnboundedSender, + components: Vec>, + config: Config, + frame_rate: f64, + last_tick_key_events: Vec, + should_quit: bool, + should_suspend: bool, + tick_rate: f64, + // App + state: State<'a>, + mode: Mode, + tasks: Tasks, +} + +impl App<'_> { + pub fn new(tick_rate: f64, frame_rate: f64) -> Result { + let (action_tx, action_rx) = mpsc::unbounded_channel(); + let disk_list_arc = Arc::new(Mutex::new(Vec::new())); + let tasks = Tasks::new(action_tx.clone(), disk_list_arc.clone()); + Ok(Self { + // TUI + action_rx, + action_tx, + components: vec![ + Box::new(Title::new("Windows Install Tool")), + Box::new(Left::new()), + Box::new(Right::new()), + Box::new(Footer::new()), + Box::new(popup::Popup::new()), + ], + config: Config::new()?, + frame_rate, + last_tick_key_events: Vec::new(), + should_quit: false, + should_suspend: false, + tick_rate, + // App + mode: Mode::default(), + state: State::new(disk_list_arc), + tasks, + }) + } + + pub async fn run(&mut self) -> Result<()> { + let mut tui = Tui::new()? + // .mouse(true) // uncomment this line to enable mouse support + .tick_rate(self.tick_rate) + .frame_rate(self.frame_rate); + tui.enter()?; + + for component in &mut self.components { + component.register_action_handler(self.action_tx.clone())?; + } + for component in &mut self.components { + component.register_config_handler(self.config.clone())?; + } + for component in &mut self.components { + component.init(tui.size()?)?; + } + + let action_tx = self.action_tx.clone(); + action_tx.send(Action::SetMode(Mode::PEMenu))?; + loop { + self.handle_events(&mut tui).await?; + self.handle_actions(&mut tui)?; + if self.should_suspend { + tui.suspend()?; + action_tx.send(Action::Resume)?; + action_tx.send(Action::ClearScreen)?; + // tui.mouse(true); + tui.enter()?; + } else if self.should_quit { + tui.stop()?; + break; + } + } + tui.exit()?; + Ok(()) + } + + async fn handle_events(&mut self, tui: &mut Tui) -> Result<()> { + let Some(event) = tui.next_event().await else { + return Ok(()); + }; + let action_tx = self.action_tx.clone(); + match event { + Event::Quit => action_tx.send(Action::Quit)?, + Event::Tick => action_tx.send(Action::Tick)?, + Event::Render => action_tx.send(Action::Render)?, + Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?, + Event::Key(key) => self.handle_key_event(key)?, + _ => {} + } + for component in &mut self.components { + if let Some(action) = component.handle_events(Some(event.clone()))? { + action_tx.send(action)?; + } + } + Ok(()) + } + + 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 { + return Ok(()); + }; + if let Some(action) = keymap.get(&vec![key]) { + info!("Got action: {action:?}"); + action_tx.send(action.clone())?; + } else { + // If the key was not handled as a single key action, + // then consider it for multi-key combinations. + self.last_tick_key_events.push(key); + + // Check for multi-key combinations + if let Some(action) = keymap.get(&self.last_tick_key_events) { + info!("Got action: {action:?}"); + action_tx.send(action.clone())?; + } + } + Ok(()) + } + + fn handle_actions(&mut self, tui: &mut Tui) -> Result<()> { + while let Ok(action) = self.action_rx.try_recv() { + if action != Action::Tick && action != Action::Render { + debug!("{action:?}"); + } + match action { + Action::Tick => { + self.last_tick_key_events.drain(..); + // Check background task(s) + self.tasks.poll()?; + } + Action::Quit => self.should_quit = true, + Action::Suspend => self.should_suspend = true, + Action::Resume => self.should_suspend = false, + Action::ClearScreen => tui.terminal.clear()?, + // Action::KeyUp => { + // self.list.previous(); + // if let Some(tool) = self.list.get_selected() + // && tool.separator + // { + // // Skip over separator + // self.list.previous(); + // if let Some(index) = self.list.selected() { + // self.action_tx.send(Action::Highlight(index))?; + // } + // } + // } + // Action::KeyDown => { + // self.list.next(); + // if let Some(tool) = self.list.get_selected() + // && tool.separator + // { + // // Skip over separator + // self.list.next(); + // if let Some(index) = self.list.selected() { + // self.action_tx.send(Action::Highlight(index))?; + // } + // } + // } + Action::Error(ref msg) => { + self.action_tx + .send(Action::DisplayPopup(popup::Type::Error, msg.clone()))?; + self.action_tx.send(Action::SetMode(Mode::Failed))?; + } + // Action::Process => { + // // Run selected tool + // if let Some(tool) = self.list.get_selected() { + // info!("Run tool: {:?}", &tool); + // self.tasks.add(build_tool_command(self, &tool)); + // } + // } + Action::Resize(w, h) => self.handle_resize(tui, w, h)?, + Action::Render => self.render(tui)?, + Action::SetMode(mode) => { + self.mode = mode; + self.action_tx.send(Action::UpdateFooter(String::from( + "(Enter) to select / (t) for terminal / (p) to power off / (r) to restart", + )))?; + //self.action_tx.send(build_left_items(self))?; + //self.action_tx.send(build_right_items(self))?; + self.action_tx.send(Action::Select(None, None))?; + } + _ => {} + } + for component in &mut self.components { + if let Some(action) = component.update(action.clone())? { + self.action_tx.send(action)?; + }; + } + } + Ok(()) + } + + fn handle_resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> Result<()> { + tui.resize(Rect::new(0, 0, w, h))?; + self.render(tui)?; + Ok(()) + } + + fn render(&mut self, tui: &mut Tui) -> Result<()> { + tui.draw(|frame| { + if let [header, _body, footer, left, right, popup] = get_chunks(frame.area())[..] { + let component_areas = vec![header, left, right, footer, popup]; + for (component, area) in zip(self.components.iter_mut(), component_areas) { + if let Err(err) = component.draw(frame, area) { + let _ = self + .action_tx + .send(Action::Error(format!("Failed to draw: {err:?}"))); + } + } + }; + })?; + Ok(()) + } +} + +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + // Cut the given rectangle into three vertical pieces + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + // Then cut the middle vertical piece into three width-wise pieces + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] // Return the middle chunk +} + +fn get_chunks(r: Rect) -> Vec { + let mut chunks: Vec = Vec::with_capacity(6); + + // Main sections + chunks.extend( + Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(1), + Constraint::Length(3), + ]) + .split(r) + .to_vec(), + ); + + // Left/Right + chunks.extend( + Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(centered_rect(90, 90, chunks[1])) + .to_vec(), + ); + + // Popup + chunks.push(centered_rect(60, 25, r)); + + // Done + chunks +} diff --git a/win_installer/src/main.rs b/win_installer/src/main.rs new file mode 100644 index 0000000..88a0f38 --- /dev/null +++ b/win_installer/src/main.rs @@ -0,0 +1,34 @@ +// 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 . +// +use clap::Parser; +use color_eyre::Result; + +use crate::app::App; + +mod app; +mod state; +mod wim; + +#[tokio::main] +async fn main() -> Result<()> { + core::errors::init()?; + core::logging::init()?; + + let args = core::cli::Cli::parse(); + let mut app = App::new(args.tick_rate, args.frame_rate)?; + app.run().await?; + Ok(()) +} diff --git a/win_installer/src/state.rs b/win_installer/src/state.rs new file mode 100644 index 0000000..48a0086 --- /dev/null +++ b/win_installer/src/state.rs @@ -0,0 +1,46 @@ +// 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 . +// + +use std::sync::{Arc, Mutex}; + +use core::system::{disk::Disk, drivers}; + +use crate::wim::WimSources; + +#[derive(Debug, Default)] +pub struct State<'a> { + pub disk_index_dest: Option, + pub disk_list: Arc>>, + pub driver: Option, + pub driver_list: Vec, + pub part_index_boot: Option, + pub wim_file_index: Option, + pub wim_image_index: Option, + pub wim_sources: Arc>>, +} + +impl State<'_> { + pub fn new(disk_list: Arc>>) -> Self { + State { + disk_list, + ..Default::default() + } + } + + pub fn scan_drivers(&mut self) { + self.driver_list = drivers::scan(); + } +} diff --git a/win_installer/src/wim.rs b/win_installer/src/wim.rs new file mode 100644 index 0000000..96cea57 --- /dev/null +++ b/win_installer/src/wim.rs @@ -0,0 +1,218 @@ +// 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 . +// +use std::{ + collections::HashMap, fmt, fs::File, io::BufReader, path::Path, process::Command, + sync::LazyLock, +}; + +use tempfile::NamedTempFile; +use xml::reader::{EventReader, XmlEvent}; + +static WIN_BUILDS: LazyLock> = LazyLock::new(|| { + HashMap::from([ + // Windows 10 + ("10240", "1507 \"Threshold 1\""), + ("10586", "1511 \"Threshold 2\""), + ("14393", "1607 \"Redstone 1\""), + ("15063", "1703 \"Redstone 2\""), + ("16299", "1709 \"Redstone 3\""), + ("17134", "1803 \"Redstone 4\""), + ("17763", "1809 \"Redstone 5\""), + ("18362", "1903 / 19H1"), + ("18363", "1909 / 19H2"), + ("19041", "2004 / 20H1"), + ("19042", "20H2"), + ("19043", "21H1"), + ("19044", "21H2"), + ("19045", "22H2"), + // Windows 11 + ("22000", "21H2"), + ("22621", "22H2"), + ("22631", "23H2"), + ("26100", "24H2"), + ("26200", "25H2"), + ]) +}); + +#[derive(Debug)] +struct WimFile<'a> { + path: &'a Path, + images: Vec, +} + +#[derive(Clone, Debug, Default)] +struct WimImage { + build: String, + index: String, + name: String, + spbuild: String, + version: String, +} + +impl WimImage { + pub fn new() -> Self { + Default::default() + } + + pub fn reset(&mut self) { + self.build.clear(); + self.index.clear(); + self.name.clear(); + self.spbuild.clear(); + self.version.clear(); + } +} + +impl fmt::Display for WimImage { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + // Windows 11 Home (24H2, 26100.xxxx) + let s = if self.version.is_empty() { + String::new() + } else { + format!("{}, ", self.version) + }; + write!(f, "{} ({}{}.{})", self.name, s, self.build, self.spbuild) + } +} + +#[derive(Debug, Default)] +pub struct WimSources<'a> { + files: Vec>, +} + +impl WimSources<'_> { + pub fn new() -> Self { + Default::default() + } +} + +fn get_wim_xml(wim_file: &str) -> std::io::Result { + let tmp_file = NamedTempFile::new()?; + let _ = Command::new("wiminfo") + .args([ + wim_file, + "--extract-xml", + tmp_file.path().as_os_str().to_str().unwrap(), + ]) + .output() + .expect("Failed to extract XML data"); + let file = File::open(tmp_file.path())?; + + Ok(file) +} + +fn parse_wim_file(wim_file: &str) -> std::io::Result> { + let mut wim_images: Vec = Vec::new(); + let wim_path = Path::new(wim_file); + if !wim_path.exists() { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Failed to read WIM file", + )); + }; + let xml_file = get_wim_xml(wim_file).expect("Failed to open XML file"); + let file = BufReader::new(xml_file); + + let mut current_element = String::new(); + let mut image = WimImage::new(); + + let parser = EventReader::new(file); + let mut depth = 0; + for e in parser { + match e { + Ok(XmlEvent::StartElement { + name, attributes, .. + }) => { + println!("{:spaces$}+{name}", "", spaces = depth * 2); + depth += 1; + current_element = name.local_name.to_uppercase(); + + if current_element == "IMAGE" { + // Update index + if let Some(attr) = attributes.first() + && attr.name.to_string().to_lowercase() == "index" + { + image.index = attr.value.clone(); + } + } + } + Ok(XmlEvent::Characters(char_data)) => { + if current_element == "BUILD" { + let build = char_data.trim(); + image.build = build.to_string(); + image.version = WIN_BUILDS.get(build).map_or("", |v| v).to_string(); + println!("{:spaces$} {char_data}", "", spaces = (depth + 1) * 2); + } + if current_element == "NAME" { + image.name = char_data.trim().to_string(); + println!("{:spaces$} {char_data}", "", spaces = (depth + 1) * 2); + } + if current_element == "SPBUILD" { + image.spbuild = char_data.trim().to_string(); + println!("{:spaces$} {char_data}", "", spaces = (depth + 1) * 2); + } + } + Ok(XmlEvent::EndElement { name }) => { + depth -= 1; + println!("{:spaces$}-{name}", "", spaces = depth * 2); + + if name.local_name.to_uppercase() == "IMAGE" { + // Append image to list + if !image.build.is_empty() && !image.name.is_empty() && !image.index.is_empty() + { + wim_images.push(image.clone()); + } + + // Reset image + image.reset() + } + } + Err(e) => { + eprintln!("Error: {e}"); + break; + } + _ => {} + } + } + let wim_file = WimFile { + path: wim_path, + images: wim_images, + }; + + Ok(wim_file) +} + +// fn main() -> std::io::Result<()> { +// let mut sources = WimSources::new(); +// if let Ok(wim_file) = parse_wim_file("./23H2.wim") +// && !wim_file.images.is_empty() +// { +// sources.files.push(wim_file); +// } +// if let Ok(wim_file) = parse_wim_file("./24H2.wim") { +// sources.files.push(wim_file); +// } +// dbg!(&sources); +// sources.files.iter().for_each(|f| { +// println!("-- {} --", f.path.to_string_lossy()); +// f.images.iter().for_each(|i| { +// println!("* {i}"); +// }); +// println!(); +// }); +// +// Ok(()) +// }