diff --git a/Cargo.lock b/Cargo.lock index e156095..f3ae8e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -689,7 +689,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2661,15 +2661,15 @@ 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", "once_cell", "rustix 1.0.3", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2933,6 +2933,16 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343e926fc669bc8cde4fa3129ab681c63671bae288b1f1081ceee6d9d37904fc" +[[package]] +name = "tui-input" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "911e93158bf80bbc94bad533b2b16e3d711e1132d69a6a6980c3920a63422c19" +dependencies = [ + "ratatui", + "unicode-width 0.2.0", +] + [[package]] name = "typenum" version = "1.18.0" @@ -3126,6 +3136,30 @@ 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", + "tui-input", + "vergen-gix", + "windows-sys 0.61.2", + "xml", +] + [[package]] name = "winapi" version = "0.3.9" @@ -3176,6 +3210,12 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-sys" version = "0.52.0" @@ -3194,6 +3234,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -3354,6 +3403,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..e166af2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,9 @@ # 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" + +[profile.release] +lto = true diff --git a/boot_diags/src/app.rs b/boot_diags/src/app.rs index dc68ffb..e804379 100644 --- a/boot_diags/src/app.rs +++ b/boot_diags/src/app.rs @@ -657,7 +657,11 @@ fn build_footer_string(cur_mode: Mode) -> String { | Mode::PEMenu | Mode::PreClone | Mode::PostClone - | Mode::SelectTableType => { + | Mode::ScanWinSources + | Mode::SelectTableType + | Mode::SelectWinSource + | Mode::SelectWinImage + | Mode::SetUserName => { panic!("This shouldn't happen?") } } @@ -770,7 +774,11 @@ fn build_left_items(app: &App) -> Action { | Mode::Confirm | Mode::PreClone | Mode::Clone - | Mode::PostClone => { + | Mode::PostClone + | Mode::ScanWinSources + | Mode::SelectWinSource + | Mode::SelectWinImage + | Mode::SetUserName => { panic!("This shouldn't happen?") } }; diff --git a/boot_diags/src/components.rs b/boot_diags/src/components.rs index 6454104..928e99a 100644 --- a/boot_diags/src/components.rs +++ b/boot_diags/src/components.rs @@ -1,2 +1,17 @@ +// 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 . +// pub mod logview; pub mod progress; diff --git a/boot_diags/src/scan.rs b/boot_diags/src/scan.rs index 7d9354b..1e80185 100644 --- a/boot_diags/src/scan.rs +++ b/boot_diags/src/scan.rs @@ -1,3 +1,18 @@ +// 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 color_eyre::Result; use core::system::disk::PartitionTableType; use core::tasks::Tasks; diff --git a/config/config.json5 b/config/config.json5 index 81c4ee1..3be6e80 100644 --- a/config/config.json5 +++ b/config/config.json5 @@ -188,5 +188,44 @@ "": "Quit", "": "Suspend" }, + "ScanWinSources": { + "": "Process", + "": "FindWimBackups", + "": "FindWimNetwork", + "": "Quit", + "": "Quit", + "": "Quit", + "": "Suspend" + }, + "SelectWinSource": { + "": "Process", + "": "KeyUp", + "": "KeyDown", + "": "PrevScreen", + "": "Quit", + "": "Quit", + "": "Quit", + "": "Suspend" + }, + "SelectWinImage": { + "": "Process", + "": "KeyUp", + "": "KeyDown", + "": "PrevScreen", + "": "Quit", + "": "Quit", + "": "Quit", + "": "Suspend" + }, + "SetUserName": { + "": "PrevScreen", + "": "Quit", + "": "Quit", + "": "Suspend" + }, }, + "network_server": "SERVER", + "network_share": "SHARE", + "network_user": "USER", + "network_pass": "PASS" } diff --git a/config/unattend.xml b/config/unattend.xml new file mode 100755 index 0000000..fe7ea54 --- /dev/null +++ b/config/unattend.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + 1 + reg add HKLM\SYSTEM\Setup\LabConfig /v BypassTPMCheck /t REG_DWORD /d 1 /f + + + 2 + reg add HKLM\SYSTEM\Setup\LabConfig /v BypassSecureBootCheck /t REG_DWORD /d 1 /f + + + 3 + reg add HKLM\SYSTEM\Setup\LabConfig /v BypassRAMCheck /t REG_DWORD /d 1 /f + + + + + + + + + 1 + reg add HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE /v BypassNRO /t REG_DWORD /d 1 /f + + + + + + + + 3 + + + + + NEWUSERNAME + NEWUSERNAME + Administrators;Power Users + + UABhAHMAcwB3AG8AcgBkAA== + false</PlainText> + </Password> + </LocalAccount> + </LocalAccounts> + </UserAccounts> + <FirstLogonCommands> + <SynchronousCommand wcm:action="add"> + <Order>1</Order> + <CommandLine>net user &quot;NEWUSERNAME&quot; /expires:never</CommandLine> + </SynchronousCommand> + <SynchronousCommand wcm:action="add"> + <Order>2</Order> + <CommandLine>net user &quot;NEWUSERNAME&quot; /passwordchg:yes</CommandLine> + </SynchronousCommand> + <SynchronousCommand wcm:action="add"> + <Order>3</Order> + <CommandLine>net user &quot;NEWUSERNAME&quot; /passwordreq:no</CommandLine> + </SynchronousCommand> + </FirstLogonCommands> + </component> + <component name="Microsoft-Windows-International-Core" processorArchitecture="amd64" language="neutral" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" publicKeyToken="31bf3856ad364e35" versionScope="nonSxS"> + <InputLocale>00000409</InputLocale> + <SystemLocale>en-US</SystemLocale> + <UserLocale>en-US</UserLocale> + <UILanguage>en-US</UILanguage> + <UILanguageFallback></UILanguageFallback> + </component> + <component name="Microsoft-Windows-SecureStartup-FilterDriver" processorArchitecture="amd64" language="neutral" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" publicKeyToken="31bf3856ad364e35" versionScope="nonSxS"> + <PreventDeviceEncryption>true</PreventDeviceEncryption> + </component> + <component name="Microsoft-Windows-EnhancedStorage-Adm" processorArchitecture="amd64" language="neutral" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" publicKeyToken="31bf3856ad364e35" versionScope="nonSxS"> + <TCGSecurityActivationDisabled>1</TCGSecurityActivationDisabled> + </component> + </settings> +</unattend> diff --git a/core/src/action.rs b/core/src/action.rs index 34bf83a..e79620d 100644 --- a/core/src/action.rs +++ b/core/src/action.rs @@ -55,6 +55,10 @@ pub enum Action { OpenTerminal, Restart, Shutdown, + // App (Win-Installer) + FindWimBackups, + FindWimNetwork, + SetUserName(String), // Screens DismissPopup, DisplayPopup(PopupType, String), diff --git a/core/src/components/popup.rs b/core/src/components/popup.rs index 2a76618..32c0292 100644 --- a/core/src/components/popup.rs +++ b/core/src/components/popup.rs @@ -22,6 +22,7 @@ use ratatui::{ use serde::{Deserialize, Serialize}; use strum::Display; use tokio::sync::mpsc::UnboundedSender; +use tracing::info; use super::Component; use crate::{action::Action, config::Config}; @@ -64,6 +65,7 @@ impl Component for Popup { match action { Action::DismissPopup => self.popup_text.clear(), Action::DisplayPopup(new_type, new_text) => { + info!("Show Popup ({new_type}): {new_text}"); self.popup_type = new_type; self.popup_text = format!("\n{new_text}"); } diff --git a/core/src/config.rs b/core/src/config.rs index 0cb107b..002c7ec 100644 --- a/core/src/config.rs +++ b/core/src/config.rs @@ -54,6 +54,14 @@ pub struct Config { pub keybindings: KeyBindings, #[serde(default)] pub styles: Styles, + #[serde(default)] + pub network_server: String, + #[serde(default)] + pub network_share: String, + #[serde(default)] + pub network_user: String, + #[serde(default)] + pub network_pass: String, } pub static PROJECT_NAME: &str = "DEJA-VU"; @@ -76,16 +84,14 @@ impl Config { let config_dir = get_config_dir(); let mut builder = config::Config::builder() .set_default("app_title", default_config.app_title.as_str())? - .set_default( - "clone_app_path", - String::from("C:\\Program Files\\Some Clone Tool\\app.exe"), - )? - .set_default( - "conemu_path", - String::from("C:\\Program Files\\ConEmu\\ConEmu64.exe"), - )? + .set_default("clone_app_path", default_config.app_title.as_str())? + .set_default("conemu_path", default_config.app_title.as_str())? .set_default("config_dir", config_dir.to_str().unwrap())? - .set_default("data_dir", data_dir.to_str().unwrap())?; + .set_default("data_dir", data_dir.to_str().unwrap())? + .set_default("network_server", default_config.app_title.as_str())? + .set_default("network_share", default_config.app_title.as_str())? + .set_default("network_user", default_config.app_title.as_str())? + .set_default("network_pass", default_config.app_title.as_str())?; let config_files = [ ("config.json5", config::FileFormat::Json5), diff --git a/core/src/line.rs b/core/src/line.rs index 18fbdf6..7b87d41 100644 --- a/core/src/line.rs +++ b/core/src/line.rs @@ -96,6 +96,12 @@ pub fn get_disk_description_right( line_colors, }); }); + if disk.parts_description.is_empty() { + description.push(DVLine { + line_parts: vec![String::from("-None-")], + line_colors: vec![Color::Reset], + }); + } description } diff --git a/core/src/state.rs b/core/src/state.rs index d05eaf5..e386a1c 100644 --- a/core/src/state.rs +++ b/core/src/state.rs @@ -42,6 +42,11 @@ pub enum Mode { Clone, SelectParts, PostClone, + // Windows Installer + ScanWinSources, + SelectWinSource, + SelectWinImage, + SetUserName, // WinPE PEMenu, } diff --git a/core/src/system/diskpart.rs b/core/src/system/diskpart.rs index 98fc74d..b1281a8 100644 --- a/core/src/system/diskpart.rs +++ b/core/src/system/diskpart.rs @@ -32,6 +32,12 @@ use crate::system::disk::{ static DEFAULT_MAX_DISKS: usize = 8; +#[derive(Debug, PartialEq)] +pub enum FormatUseCase { + ApplyWimImage, + Clone, +} + pub struct RegexList { detail_all_disks: OnceLock<Regex>, detail_disk: OnceLock<Regex>, @@ -205,7 +211,11 @@ pub fn get_partitions( } #[must_use] -pub fn build_dest_format_script(disk_id: usize, part_type: &PartitionTableType) -> String { +pub fn build_dest_format_script( + disk_id: usize, + part_type: &PartitionTableType, + format_use_case: FormatUseCase, +) -> String { let disk_id = format!("{disk_id}"); let mut script = vec!["automount enable noerr", "select disk {disk_id}", "clean"]; match part_type { @@ -221,6 +231,10 @@ pub fn build_dest_format_script(disk_id: usize, part_type: &PartitionTableType) script.push("format fs=ntfs quick label=System"); } } + if format_use_case == FormatUseCase::ApplyWimImage { + script.push("create partition primary"); + script.push("format fs=ntfs quick label=Windows"); + } script.join("\r\n").replace("{disk_id}", &disk_id) } diff --git a/deja_vu/src/app.rs b/deja_vu/src/app.rs index 34afb20..dc8ccd6 100644 --- a/deja_vu/src/app.rs +++ b/deja_vu/src/app.rs @@ -28,7 +28,10 @@ use core::{ line::{DVLine, get_disk_description_right, get_part_description}, state::Mode, system::{ - boot, cpu::get_cpu_name, disk::PartitionTableType, diskpart::build_dest_format_script, + boot, + cpu::get_cpu_name, + disk::PartitionTableType, + diskpart::{FormatUseCase, build_dest_format_script}, drivers, }, tasks::{Task, TaskResult, TaskType, Tasks}, @@ -129,7 +132,11 @@ impl App { | Mode::LogView | Mode::PEMenu | Mode::Process - | Mode::SetBootMode => panic!("This shouldn't happen?"), + | Mode::ScanWinSources + | Mode::SelectWinSource + | Mode::SelectWinImage + | Mode::SetBootMode + | Mode::SetUserName => panic!("This shouldn't happen?"), } } @@ -154,7 +161,11 @@ impl App { | Mode::LogView | Mode::PEMenu | Mode::Process - | Mode::SetBootMode => panic!("This shouldn't happen?"), + | Mode::ScanWinSources + | Mode::SelectWinSource + | Mode::SelectWinImage + | Mode::SetBootMode + | Mode::SetUserName => panic!("This shouldn't happen?"), }; if new_mode == self.cur_mode { @@ -201,7 +212,8 @@ impl App { && let Some(disk) = disk_list.get(disk_index) { let table_type = self.state.table_type.clone().unwrap(); - let diskpart_script = build_dest_format_script(disk.id, &table_type); + let diskpart_script = + build_dest_format_script(disk.id, &table_type, FormatUseCase::Clone); self.tasks.add(TaskType::Diskpart(diskpart_script)); } } @@ -461,9 +473,8 @@ impl App { self.set_mode(new_mode)?; self.action_tx .send(Action::UpdateFooter(build_footer_string(self.cur_mode)))?; - self.action_tx.send(build_left_items(self, self.cur_mode))?; - self.action_tx - .send(build_right_items(self, self.cur_mode))?; + self.action_tx.send(build_left_items(self))?; + self.action_tx.send(build_right_items(self))?; match new_mode { Mode::SelectTableType | Mode::Confirm => { // Select source/dest disks @@ -634,16 +645,20 @@ fn build_footer_string(cur_mode: Mode) -> String { | Mode::LogView | Mode::PEMenu | Mode::Process - | Mode::SetBootMode => panic!("This shouldn't happen?"), + | Mode::ScanWinSources + | Mode::SelectWinSource + | Mode::SelectWinImage + | Mode::SetBootMode + | Mode::SetUserName => panic!("This shouldn't happen?"), } } -fn build_left_items(app: &App, cur_mode: Mode) -> Action { +fn build_left_items(app: &App) -> Action { let select_type: SelectionType; let title: String; let mut items = Vec::new(); let mut labels: Vec<String> = Vec::new(); - match cur_mode { + match app.cur_mode { Mode::Home => { select_type = SelectionType::Loop; title = String::from("Home"); @@ -707,16 +722,20 @@ fn build_left_items(app: &App, cur_mode: Mode) -> Action { | Mode::LogView | Mode::PEMenu | Mode::Process - | Mode::SetBootMode => panic!("This shouldn't happen?"), + | Mode::ScanWinSources + | Mode::SelectWinSource + | Mode::SelectWinImage + | Mode::SetBootMode + | Mode::SetUserName => panic!("This shouldn't happen?"), }; Action::UpdateLeft(title, labels, items, select_type) } -fn build_right_items(app: &App, cur_mode: Mode) -> Action { +fn build_right_items(app: &App) -> Action { let mut items = Vec::new(); let mut labels: Vec<Vec<DVLine>> = Vec::new(); let mut start_index = 0; - match cur_mode { + match app.cur_mode { Mode::InstallDrivers => { items.push(vec![DVLine { line_parts: vec![String::from("CPU")], diff --git a/win_installer/Cargo.toml b/win_installer/Cargo.toml new file mode 100644 index 0000000..8dc0dd2 --- /dev/null +++ b/win_installer/Cargo.toml @@ -0,0 +1,50 @@ +# 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/>. + +[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" +windows-sys = { version = "0.61.1", features = ["Win32_NetworkManagement_WNet"] } +xml = "1.1.0" +tui-input = "0.14.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 <https://www.gnu.org/licenses/>. +// +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..c634696 --- /dev/null +++ b/win_installer/src/app.rs @@ -0,0 +1,1082 @@ +// 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 core::{ + action::Action, + components::{ + Component, + footer::Footer, + left::{Left, SelectionType}, + popup, + right::Right, + title::Title, + }, + config::Config, + line::{DVLine, get_disk_description_right}, + state::Mode, + system::{ + boot, + cpu::get_cpu_name, + disk::PartitionTableType, + diskpart::{FormatUseCase, build_dest_format_script}, + drivers, + }, + tasks::{Task, TaskResult, TaskType, Tasks}, + tui::{Event, Tui}, +}; +use std::{ + env, + fs::{File, create_dir_all}, + io::Write, + iter::zip, + path::PathBuf, + sync::{Arc, Mutex}, +}; + +use color_eyre::Result; +use ratatui::{ + crossterm::event::KeyEvent, + layout::{Constraint, Direction, Layout}, + prelude::Rect, + style::Color, +}; +use tokio::sync::mpsc; +use tracing::{debug, error, info}; + +use crate::{ + components::{set_username::InputUsername, wim_scan::WimScan}, + state::{ScanType, State}, + wim::gen_unattend_xml, +}; + +pub struct App { + // TUI + action_rx: mpsc::UnboundedReceiver<Action>, + action_tx: mpsc::UnboundedSender<Action>, + components: Vec<Box<dyn Component>>, + config: Config, + frame_rate: f64, + last_tick_key_events: Vec<KeyEvent>, + should_quit: bool, + should_suspend: bool, + tick_rate: f64, + // App + state: State, + cur_mode: Mode, + tasks: Tasks, +} + +impl App { + pub fn new(tick_rate: f64, frame_rate: f64) -> Result<Self> { + let (action_tx, action_rx) = mpsc::unbounded_channel(); + let config = Config::new()?; + let disk_list_arc = Arc::new(Mutex::new(Vec::new())); + let tasks = Tasks::new(action_tx.clone(), disk_list_arc.clone()); + let state = State::new(config.clone(), disk_list_arc); + let wim_sources = Arc::clone(&state.wim_sources); + 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(WimScan::new(wim_sources)), + Box::new(InputUsername::new()), + Box::new(Footer::new()), + Box::new(popup::Popup::new()), + ], + config, + frame_rate, + last_tick_key_events: Vec::new(), + should_quit: false, + should_suspend: false, + tick_rate, + // App + cur_mode: Mode::default(), + state, + tasks, + }) + } + + pub fn next_mode(&mut self) -> Mode { + match self.cur_mode { + Mode::Home | Mode::InstallDrivers => Mode::ScanDisks, + Mode::ScanDisks => Mode::SelectDisks, + Mode::SelectDisks => Mode::SelectTableType, + Mode::SelectTableType => Mode::ScanWinSources, + Mode::ScanWinSources => Mode::SelectWinSource, + Mode::SelectWinSource => Mode::SelectWinImage, + Mode::SelectWinImage => Mode::SetUserName, + Mode::SetUserName => Mode::Confirm, + Mode::Confirm => Mode::PreClone, + Mode::PreClone => Mode::Clone, + Mode::Clone => Mode::PostClone, + Mode::PostClone | Mode::Done => Mode::Done, + Mode::Failed => Mode::Failed, + // Invalid States + Mode::BootDiags + | Mode::BootScan + | Mode::BootSetup + | Mode::DiagMenu + | Mode::InjectDrivers + | Mode::LogView + | Mode::PEMenu + | Mode::Process + | Mode::SelectParts + | Mode::SetBootMode => panic!("This shouldn't happen?"), + } + } + + pub fn set_mode(&mut self, new_mode: Mode) -> Result<()> { + info!("Setting mode to {new_mode:?}"); + self.cur_mode = new_mode; + match new_mode { + Mode::InstallDrivers => self.state.scan_drivers(), + // Mode::Process => { + // self.action_tx + // .send(Action::DisplayPopup(popup::Type::Info, String::from("...")))?; + // } + Mode::PreClone => { + self.action_tx.send(Action::DisplayPopup( + popup::Type::Info, + String::from("Formatting destination disk"), + ))?; + + // Get System32 path + let system32 = get_system32_path(&self.action_tx); + + // (Re)Enable volume mounting + self.tasks.add(TaskType::CommandWait( + PathBuf::from(format!("{system32}/mountvol.exe")), + vec![String::from("/e")], + )); + + // Build Diskpart script to format destination disk + let disk_list = self.state.disk_list.lock().unwrap(); + if let Some(disk_index) = self.state.disk_index_dest + && let Some(disk) = disk_list.get(disk_index) + { + let table_type = self.state.table_type.clone().unwrap(); + let diskpart_script = build_dest_format_script( + disk.id, + &table_type, + FormatUseCase::ApplyWimImage, + ); + self.tasks.add(TaskType::Diskpart(diskpart_script)); + } + + // Update drive letters + self.tasks.add(TaskType::Sleep); + if let Some(dest_index) = self.state.disk_index_dest { + self.tasks.add(TaskType::UpdateDestDisk(dest_index)); + } + } + Mode::Clone => { + self.action_tx.send(Action::DisplayPopup( + popup::Type::Info, + String::from("Applying Image"), + ))?; + + // Get wimlib-imagex path + let program_files = PathBuf::from(get_program_files_path(&self.action_tx)); + let wimlib_imagex = program_files.join("wimlib\\wimlib-imagex.exe"); + + // Get image info + let wim_sources = self.state.wim_sources.lock().unwrap(); + let wim_file = wim_sources.get_file(self.state.wim_file_index.unwrap()); + let wim_index = self.state.wim_image_index.unwrap() + 1; // wimapply uses 1-based index + + // Add actions + let disk_list = self.state.disk_list.lock().unwrap(); + if let Some(disk_index) = self.state.disk_index_dest + && let Some(disk) = disk_list.get(disk_index) + { + let num_parts = disk.parts.len(); + let dest_path = format!("{}:\\", disk.get_part_letter(num_parts - 1)); + self.tasks.add(TaskType::CommandWait( + wimlib_imagex, + vec![ + String::from("apply"), + wim_file.path, + format!("{wim_index}"), + dest_path, + ], + )); + } + } + Mode::PostClone => { + self.action_tx.send(Action::DisplayPopup( + popup::Type::Info, + String::from("Updating boot configuration"), + ))?; + let wim_sources = self.state.wim_sources.lock().unwrap(); + let wim_file = wim_sources.get_file(self.state.wim_file_index.unwrap()); + + // Get System32 path + let system32 = get_system32_path(&self.action_tx); + + // Add actions + let disk_list = self.state.disk_list.lock().unwrap(); + if let Some(disk_index) = self.state.disk_index_dest + && let Some(disk) = disk_list.get(disk_index) + { + let table_type = self.state.table_type.clone().unwrap(); + let letter_boot = disk.get_part_letter(0); + let letter_os = + disk.get_part_letter(match self.state.table_type.clone().unwrap() { + PartitionTableType::Guid => 2, + PartitionTableType::Legacy => 1, + }); + info!("PostClone // Disk: {disk:?}"); + info!("\t\tBoot letter: {letter_boot}"); + info!("\t\tOS letter: {letter_os}"); + + // Safety check + if letter_boot.is_empty() || letter_os.is_empty() { + self.action_tx.send(Action::Error(String::from( + "ERROR\n\n\nFailed to get drive letters for the destination", + )))?; + return Ok(()); + } + + // Create boot files + let safe_mode = if wim_file.is_backup { + boot::SafeMode::Enable + } else { + boot::SafeMode::Disable + }; + for task in boot::configure_disk( + &letter_boot, + &letter_os, + safe_mode, + &system32, + &table_type, + ) { + self.tasks.add(task); + } + + // Inject driver(s) (if selected) + if let Some(driver) = &self.state.driver { + if let Ok(task) = boot::inject_driver(driver, &letter_os, &system32) { + self.tasks.add(task); + } else { + self.action_tx.send(Action::Error(format!( + "Failed to inject driver:\n{}", + driver.name + )))?; + } + } + + // Add unattend.xml (if applicable) + if let Some(username) = &self.state.username + && !wim_file.is_backup + { + let unattend_xml_str = gen_unattend_xml(username); + let panther_path = format!("{letter_os}:\\Windows\\Panther"); + if create_dir_all(PathBuf::from(&panther_path)).is_ok() { + if let Ok(mut unattend_xml) = + File::create(format!("{panther_path}\\unattend.xml")) + { + if unattend_xml.write_all(unattend_xml_str.as_bytes()).is_ok() { + info!("Created unattend.xml with username set to: {username}"); + } else { + error!("Failed to write to unattend.xml"); + } + } else { + error!("Failed to create unattend.xml"); + } + } else { + error!("Failed to create Panther dir"); + } + } + } + } + Mode::ScanDisks => { + self.state.reset_all(); + if self.tasks.idle() { + self.tasks.add(TaskType::ScanDisks); + } + self.action_tx.send(Action::DisplayPopup( + popup::Type::Info, + String::from("Scanning Disks..."), + ))?; + } + Mode::ScanWinSources => { + self.state.reset_all(); + self.state.scan_wim_local(ScanType::WindowsInstallers); + } + Mode::SetUserName => { + if let Ok(wim_sources) = self.state.wim_sources.lock() + && let Some(index) = self.state.wim_image_index + { + let image = wim_sources.get_file(index); + if image.is_backup { + self.action_tx.send(Action::NextScreen)?; + } + } + } + Mode::Done => { + self.action_tx + .send(Action::DisplayPopup(popup::Type::Success, popup::fortune()))?; + } + _ => {} + } + Ok(()) + } + + 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::ScanDisks))?; + 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.cur_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) + if let Some(task) = self.tasks.poll()? { + self.handle_task(&task)?; + } + if let Ok(mut wim_sources) = self.state.wim_sources.lock() { + wim_sources.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::Resize(w, h) => self.handle_resize(tui, w, h)?, + Action::Render => self.render(tui)?, + Action::InstallDriver => { + self.action_tx.send(Action::SetMode(Mode::InstallDrivers))?; + } + Action::FindWimBackups => { + self.state.reset_local(); + self.state.scan_wim_local(ScanType::GeneralWimFiles); + } + Action::FindWimNetwork => { + self.state.reset_network(); + self.state.scan_wim_network(); + } + Action::NextScreen => { + let next_mode = self.next_mode(); + self.action_tx.send(Action::DismissPopup)?; + self.action_tx.send(Action::SetMode(next_mode))?; + } + Action::DisplayPopup(ref popup_type, ref _popup_text) => { + if *popup_type == popup::Type::Error { + self.action_tx.send(Action::SetMode(Mode::Failed))?; + } + } + Action::PrevScreen => match self.cur_mode { + Mode::SelectTableType => { + self.action_tx.send(Action::SetMode(Mode::SelectDisks))?; + } + Mode::SelectWinSource => { + self.action_tx.send(Action::SetMode(Mode::ScanWinSources))?; + } + Mode::SelectWinImage | Mode::SetUserName | Mode::Confirm => { + self.action_tx + .send(Action::SetMode(Mode::SelectWinSource))?; + } + _ => {} + }, + Action::Process => match self.cur_mode { + Mode::Confirm | Mode::ScanWinSources => { + self.action_tx.send(Action::NextScreen)?; + } + Mode::Done => { + self.action_tx.send(Action::Quit)?; + } + _ => {} + }, + Action::ScanDisks => self.action_tx.send(Action::SetMode(Mode::ScanDisks))?, + Action::Select(one, _two) => match self.cur_mode { + Mode::InstallDrivers => { + if let Some(index) = one + && let Some(driver) = self.state.driver_list.get(index).cloned() + { + drivers::load(&driver.inf_paths); + self.state.driver = Some(driver); + } + } + Mode::SelectDisks => { + self.state.disk_index_dest = one; + } + Mode::SelectTableType => { + self.state.table_type = { + if let Some(index) = one { + match index { + 0 => Some(PartitionTableType::Guid), + 1 => Some(PartitionTableType::Legacy), + index => { + panic!("Failed to select PartitionTableType: {index}") + } + } + } else { + None + } + } + } + Mode::SelectWinSource => { + self.state.wim_file_index = one; + } + Mode::SelectWinImage => { + self.state.wim_image_index = one; + } + _ => {} + }, + Action::SetMode(mode) => { + self.set_mode(mode)?; + self.action_tx + .send(Action::UpdateFooter(build_footer_string(self.cur_mode)))?; + self.action_tx.send(build_left_items(self))?; + self.action_tx.send(build_right_items(self))?; + self.action_tx.send(Action::Select(None, None))?; + } + Action::SetUserName(ref name) => { + self.state.username = Some(name.clone()); + self.action_tx.send(Action::NextScreen)?; + } + Action::TasksComplete => self.action_tx.send(Action::NextScreen)?, + _ => {} + } + 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 handle_task(&mut self, task: &Task) -> Result<()> { + match task.task_type { + TaskType::CommandWait(_, _) | TaskType::Diskpart(_) => { + // Check result + if let Some(result) = &task.result { + match result { + TaskResult::Error(msg) => { + self.action_tx + .send(Action::Error(format!("{} Failed: {msg}", task.task_type)))?; + } + TaskResult::Output(stdout, stderr, success) => { + if !success { + let msg = if !stdout.is_empty() { + stdout.clone() + } else if !stderr.is_empty() { + stderr.clone() + } else { + String::from("Unknown Error") + }; + self.action_tx.send(Action::Error(format!( + "{} Failed: {msg}", + task.task_type + )))?; + } + } + } + } + } + _ => {} + } + Ok(()) + } + + fn render(&mut self, tui: &mut Tui) -> Result<()> { + tui.draw(|frame| { + if let [header, _body, footer, center, left, right, username, popup] = + get_chunks(frame.area())[..] + { + let component_areas = vec![header, center, left, right, username, 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 build_footer_string(cur_mode: Mode) -> String { + match cur_mode { + Mode::Home | Mode::ScanDisks | Mode::PreClone | Mode::Clone | Mode::PostClone => { + String::from("(q) to quit") + } + Mode::InstallDrivers => String::from("(Enter) to select / (q) to quit"), + Mode::SelectDisks => String::from( + "(Enter) to select / / (i) to install driver / (r) to rescan / (q) to quit", + ), + Mode::SelectTableType => String::from("(Enter) to select / (b) to go back / (q) to quit"), + Mode::SelectWinSource | Mode::SelectWinImage => { + String::from("(Enter) to select / (b) to go back / (q) to quit") + } + Mode::ScanWinSources => String::from( + "(Enter) to continue / (b) to scan for backups / (n) to scan network / (q) to quit", + ), + Mode::SetUserName => String::from("(Enter) to continue / (Esc) to go back"), + Mode::Confirm => String::from("(Enter) to confirm / (b) to go back / (q) to quit"), + Mode::Done | Mode::Failed => String::from("(Enter) or (q) to quit"), + // Invalid States + Mode::BootDiags + | Mode::BootScan + | Mode::BootSetup + | Mode::DiagMenu + | Mode::InjectDrivers + | Mode::LogView + | Mode::PEMenu + | Mode::Process + | Mode::SelectParts + | Mode::SetBootMode => panic!("This shouldn't happen?"), + } +} + +fn build_left_items(app: &App) -> Action { + let select_type: SelectionType; + let title: String; + let mut items = Vec::new(); + let mut labels: Vec<String> = Vec::new(); + match app.cur_mode { + Mode::Home => { + select_type = SelectionType::Loop; + title = String::from("Home"); + } + Mode::InstallDrivers => { + select_type = SelectionType::One; + title = String::from("Install Drivers"); + app.state + .driver_list + .iter() + .for_each(|driver| items.push(driver.to_string())); + } + Mode::ScanWinSources => { + select_type = SelectionType::Loop; + title = String::from("Scanning"); + } + Mode::SelectWinSource => { + select_type = SelectionType::One; + title = String::from("Select Windows Source"); + if let Ok(wim_sources) = app.state.wim_sources.lock() { + wim_sources + .get_file_list() + .iter() + .for_each(|wim_file| items.push(wim_file.path.clone())); + } + } + Mode::SelectWinImage | Mode::SetUserName => { + select_type = SelectionType::One; + title = String::from("Select Windows Image"); + if let Ok(wim_sources) = app.state.wim_sources.lock() + && let Some(index) = app.state.wim_file_index + { + wim_sources + .get_file(index) + .images + .iter() + .for_each(|image| items.push(format!("{image}"))); + } + } + Mode::SelectDisks => { + select_type = SelectionType::One; + title = String::from("Select Destination Disk"); + let disk_list = app.state.disk_list.lock().unwrap(); + disk_list + .iter() + .for_each(|disk| items.push(disk.description.to_string())); + } + Mode::SelectTableType => { + select_type = SelectionType::One; + title = String::from("Select Partition Table Type"); + items.push(format!("{}", PartitionTableType::Guid)); + items.push(format!("{}", PartitionTableType::Legacy)); + } + Mode::Confirm => { + select_type = SelectionType::Loop; + title = String::from("Confirm Selections"); + } + Mode::ScanDisks | Mode::PreClone | Mode::Clone | Mode::PostClone => { + select_type = SelectionType::Loop; + title = String::from("Processing"); + } + Mode::Done | Mode::Failed => { + select_type = SelectionType::Loop; + title = String::from("Done"); + } + // Invalid states + Mode::BootDiags + | Mode::BootScan + | Mode::BootSetup + | Mode::DiagMenu + | Mode::Process + | Mode::InjectDrivers + | Mode::LogView + | Mode::PEMenu + | Mode::SelectParts + | Mode::SetBootMode => panic!("This shouldn't happen?"), + }; + Action::UpdateLeft(title, labels, items, select_type) +} + +fn build_right_items(app: &App) -> Action { + let mut items = Vec::new(); + let mut labels: Vec<Vec<DVLine>> = Vec::new(); + let mut start_index = 0; + match app.cur_mode { + Mode::InstallDrivers => { + items.push(vec![DVLine { + line_parts: vec![String::from("CPU")], + line_colors: vec![Color::Cyan], + }]); + items.push(vec![DVLine { + line_parts: vec![get_cpu_name()], + line_colors: vec![Color::Reset], + }]); + start_index = 2; + } + Mode::SelectDisks => { + // Labels + let dest_dv_line = DVLine { + line_parts: vec![ + String::from("Dest"), + String::from(" (WARNING: ALL DATA WILL BE DELETED!)"), + ], + line_colors: vec![Color::Cyan, Color::Red], + }; + if let Some(table_type) = &app.state.table_type { + // Show table type + let type_str = match table_type { + PartitionTableType::Guid => "GPT", + PartitionTableType::Legacy => "MBR", + }; + labels.push(vec![ + dest_dv_line, + DVLine { + line_parts: vec![format!(" (Will be formatted {type_str})")], + line_colors: vec![Color::Yellow], + }, + ]); + } else { + labels.push(vec![dest_dv_line]); + } + let disk_list = app.state.disk_list.lock().unwrap(); + disk_list + .iter() + .for_each(|disk| items.push(get_disk_description_right(disk, &None))); + } + Mode::SelectWinSource => { + // Disk Info + let type_str = match app.state.table_type.clone().unwrap() { + PartitionTableType::Guid => "GPT", + PartitionTableType::Legacy => "MBR", + }; + let mut label_dv_lines = vec![ + DVLine { + line_parts: vec![ + String::from("Dest"), + String::from(" (WARNING: ALL DATA WILL BE DELETED!)"), + ], + line_colors: vec![Color::Cyan, Color::Red], + }, + DVLine { + line_parts: vec![format!(" (Will be formatted {type_str})")], + line_colors: vec![Color::Yellow], + }, + DVLine::blank(), + ]; + let disk_list = app.state.disk_list.lock().unwrap(); + if let Some(index) = app.state.disk_index_dest + && let Some(disk) = disk_list.get(index) + { + get_disk_description_right(disk, &None) + .into_iter() + .for_each(|dv_line| label_dv_lines.push(dv_line)); + } + labels.push(label_dv_lines); + + // WIM Info + if let Ok(wim_sources) = app.state.wim_sources.lock() { + wim_sources.get_file_list().iter().for_each(|source| { + let mut wim_dv_lines = vec![ + DVLine { + line_parts: vec![String::from("WIM Info")], + line_colors: vec![Color::Cyan], + }, + DVLine { + line_parts: vec![source.path.clone()], + line_colors: vec![Color::Reset], + }, + DVLine::blank(), + DVLine { + line_parts: vec![String::from("Images")], + line_colors: vec![Color::Blue], + }, + DVLine::blank(), + ]; + source.images.iter().for_each(|image| { + wim_dv_lines.push(DVLine { + line_parts: vec![format!("{image}")], + line_colors: vec![Color::Reset], + }) + }); + items.push(wim_dv_lines); + }); + } + } + Mode::SelectWinImage + | Mode::SetUserName + | Mode::Confirm + | Mode::PreClone + | Mode::Clone + | Mode::PostClone => { + info!("Building right items for: {:?}", &app.cur_mode); + let wim_file; + if let Ok(wim_sources) = app.state.wim_sources.lock() + && let Some(index) = app.state.wim_file_index + { + wim_file = wim_sources.get_file(index); + } else { + panic!("Failed to get source WIM file"); + } + // Disk Info + let type_str = match app.state.table_type.clone().unwrap() { + PartitionTableType::Guid => "GPT", + PartitionTableType::Legacy => "MBR", + }; + let mut label_dv_lines = vec![ + DVLine { + line_parts: vec![ + String::from("Dest"), + String::from(" (WARNING: ALL DATA WILL BE DELETED!)"), + ], + line_colors: vec![Color::Cyan, Color::Red], + }, + DVLine { + line_parts: vec![format!(" (Will be formatted {type_str})")], + line_colors: vec![Color::Yellow], + }, + DVLine::blank(), + ]; + let disk_list = app.state.disk_list.lock().unwrap(); + if let Some(index) = app.state.disk_index_dest + && let Some(disk) = disk_list.get(index) + { + get_disk_description_right(disk, &None) + .into_iter() + .for_each(|dv_line| label_dv_lines.push(dv_line)); + } + label_dv_lines.append(&mut vec![ + DVLine::blank(), + DVLine { + line_parts: vec![String::from("WIM Info")], + line_colors: vec![Color::Cyan], + }, + DVLine { + line_parts: vec![wim_file.path.clone()], + line_colors: vec![Color::Reset], + }, + DVLine::blank(), + DVLine { + line_parts: vec![String::from("Image")], + line_colors: vec![Color::Blue], + }, + ]); + + // WIM Info + match app.cur_mode { + Mode::SelectWinImage => { + wim_file.images.iter().for_each(|image| { + items.push(vec![DVLine { + line_parts: vec![format!("{image}")], + line_colors: vec![Color::Reset], + }]) + }); + } + Mode::Confirm | Mode::PreClone | Mode::Clone | Mode::PostClone => { + if let Some(index) = app.state.wim_image_index + && let Some(image) = wim_file.images.get(index) + { + label_dv_lines.append(&mut vec![ + DVLine { + line_parts: vec![format!("{image}")], + line_colors: vec![Color::Reset], + }, + DVLine::blank(), + ]); + } + if !wim_file.is_backup + && let Some(username) = &app.state.username + { + label_dv_lines.append(&mut vec![DVLine { + line_parts: vec![String::from("Username: "), username.clone()], + line_colors: vec![Color::Green, Color::Reset], + }]); + } + items.push(vec![DVLine::blank()]); + } + _ => {} + } + + // Done + labels.push(label_dv_lines); + } + Mode::SelectTableType => { + // Labels + let dest_dv_line = DVLine { + line_parts: vec![ + String::from("Dest"), + String::from(" (WARNING: ALL DATA WILL BE DELETED!)"), + ], + line_colors: vec![Color::Cyan, Color::Red], + }; + if let Some(table_type) = &app.state.table_type { + // Show table type + let type_str = match table_type { + PartitionTableType::Guid => "GPT", + PartitionTableType::Legacy => "MBR", + }; + labels.push(vec![ + dest_dv_line, + DVLine { + line_parts: vec![format!(" (Will be formatted {type_str})")], + line_colors: vec![Color::Yellow], + }, + ]); + } else { + labels.push(vec![dest_dv_line]); + } + let disk_list = app.state.disk_list.lock().unwrap(); + if let Some(index) = app.state.disk_index_dest + && let Some(disk) = disk_list.get(index) + { + items.push(get_disk_description_right(disk, &None)); + } + } + _ => {} + } + Action::UpdateRight(labels, start_index, items) +} + +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<Rect> { + let mut chunks: Vec<Rect> = 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(), + ); + let center = centered_rect(90, 90, chunks[1]); + + // Left/Right + chunks.extend( + Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(center) + .to_vec(), + ); + + // Center + chunks.push(center); + + // Set username + chunks.push(centered_rect(60, 20, r)); + + // Popup + chunks.push(centered_rect(60, 25, r)); + + // Done + chunks +} + +pub fn get_program_files_path(action_tx: &mpsc::UnboundedSender<Action>) -> String { + let mut program_files_path = String::from("."); + if cfg!(windows) { + if let Ok(path) = env::var("PROGRAMFILES") { + program_files_path = path; + } else { + action_tx + .send(Action::Error(String::from( + "ERROR\n\n\nFailed to find PROGRAMFILES", + ))) + .expect("Failed to find PROGRAMFILES and then failed to send action"); + } + } + program_files_path +} + +pub fn get_system32_path(action_tx: &mpsc::UnboundedSender<Action>) -> String { + let mut system32_path = String::from("."); + if cfg!(windows) { + if let Ok(path) = env::var("SYSTEMROOT") { + system32_path = format!("{path}/System32"); + } else { + action_tx + .send(Action::Error(String::from( + "ERROR\n\n\nFailed to find SYSTEMROOT", + ))) + .expect("Failed to find SYSTEMROOT and then failed to send action"); + } + } + system32_path +} diff --git a/win_installer/src/components.rs b/win_installer/src/components.rs new file mode 100644 index 0000000..92f5847 --- /dev/null +++ b/win_installer/src/components.rs @@ -0,0 +1,17 @@ +// 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 set_username; +pub mod wim_scan; diff --git a/win_installer/src/components/set_username.rs b/win_installer/src/components/set_username.rs new file mode 100644 index 0000000..ff63778 --- /dev/null +++ b/win_installer/src/components/set_username.rs @@ -0,0 +1,135 @@ +// 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 color_eyre::Result; +use core::{action::Action, components::Component, config::Config, state::Mode, tui::Event}; +use crossterm::event::KeyCode; +use ratatui::{ + prelude::*, + widgets::{Block, Borders, Clear, Paragraph}, +}; +use tokio::sync::mpsc::UnboundedSender; +use tui_input::{Input, InputRequest}; + +#[derive(Default)] +pub struct InputUsername { + command_tx: Option<UnboundedSender<Action>>, + config: Config, + input: Input, + mode: Mode, +} + +impl InputUsername { + #[must_use] + pub fn new() -> Self { + Self { + input: Input::new(String::from("")), + ..Default::default() + } + } +} + +impl Component for InputUsername { + fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> { + self.command_tx = Some(tx); + Ok(()) + } + + fn register_config_handler(&mut self, config: Config) -> Result<()> { + self.config = config; + Ok(()) + } + + fn handle_events(&mut self, event: Option<Event>) -> Result<Option<Action>> { + if self.mode != Mode::SetUserName { + return Ok(None); + } + let action = match event { + Some(Event::Key(key_event)) => match key_event.code { + KeyCode::Backspace => { + self.input.handle(InputRequest::DeletePrevChar); + None + } + KeyCode::Char(c) => { + let ok_chars: Vec<char> = vec![' ', '-', '_']; + if c.is_ascii_alphanumeric() || ok_chars.contains(&c) { + self.input.handle(InputRequest::InsertChar(c)); + } + None + } + KeyCode::Enter => { + let username = self.input.value(); + Some(Action::SetUserName(String::from(username))) + } + KeyCode::Esc => Some(Action::SetMode(Mode::Home)), + _ => None, + }, + Some(Event::Mouse(_)) => None, + _ => None, + }; + Ok(action) + } + + fn update(&mut self, action: Action) -> Result<Option<Action>> { + match action { + Action::SetMode(mode) => self.mode = mode.clone(), + _ => {} + } + Ok(None) + } + + fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + if self.mode != Mode::SetUserName { + // Bail early + return Ok(()); + } + + // Set areas + let [_, center_area, _] = Layout::horizontal([ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + ]) + .areas(area); + let [_, input_area, _] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(3), + Constraint::Length(1), + ]) + .areas(center_area); + + frame.render_widget(Clear, area); + let outer_block = Block::bordered().cyan().bold(); + frame.render_widget(outer_block, area); + + // Input Box + let width = input_area.width.max(3) - 3; // keep 2 for borders and 1 for cursor + let scroll = self.input.visual_scroll(width as usize); + let input = Paragraph::new(self.input.value()) + .scroll((0, scroll as u16)) + .white() + .block(Block::new().borders(Borders::ALL).title("Enter Username")); + frame.render_widget(input, input_area); + + // Ratatui hides the cursor unless it's explicitly set. Position the cursor past the + // end of the input text and one line down from the border to the input line + let x = self.input.visual_cursor().max(scroll) - scroll + 1; + frame.set_cursor_position((input_area.x + x as u16, input_area.y + 1)); + + // Done + Ok(()) + } +} diff --git a/win_installer/src/components/wim_scan.rs b/win_installer/src/components/wim_scan.rs new file mode 100644 index 0000000..cb5abc1 --- /dev/null +++ b/win_installer/src/components/wim_scan.rs @@ -0,0 +1,154 @@ +// 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 core::{action::Action, components::Component, config::Config, state::Mode}; +use std::{ + iter::zip, + sync::{Arc, Mutex}, +}; + +use color_eyre::Result; +use crossterm::event::KeyEvent; +use ratatui::{ + prelude::*, + widgets::{Block, Borders, Clear, List, ListItem, Padding, Paragraph}, +}; +use tokio::sync::mpsc::UnboundedSender; + +use crate::wim::WimSources; + +#[derive(Default)] +pub struct WimScan { + command_tx: Option<UnboundedSender<Action>>, + config: Config, + mode: Mode, + scan_network: bool, + wim_sources: Arc<Mutex<WimSources>>, +} + +impl WimScan { + #[must_use] + pub fn new(wim_sources: Arc<Mutex<WimSources>>) -> Self { + let wim_sources = wim_sources.clone(); + Self { + wim_sources, + ..Default::default() + } + } +} + +impl Component for WimScan { + fn handle_key_event(&mut self, key: KeyEvent) -> Result<Option<Action>> { + let _ = key; // to appease clippy + Ok(None) + } + + fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> { + self.command_tx = Some(tx); + Ok(()) + } + + fn register_config_handler(&mut self, config: Config) -> Result<()> { + self.config = config; + Ok(()) + } + + fn update(&mut self, action: Action) -> Result<Option<Action>> { + match action { + Action::FindWimNetwork => self.scan_network = true, + Action::SetMode(new_mode) => { + self.mode = new_mode; + } + _ => {} + } + Ok(None) + } + + fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + if self.mode != Mode::ScanWinSources { + return Ok(()); + } + frame.render_widget(Clear, area); + + // Prep + let [left, right] = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .areas(area); + let [left_title, left_body] = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(1)]) + .areas(left); + let [right_title, right_body] = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(1)]) + .areas(right); + + // Titles + let titles = vec![ + Paragraph::new(Line::from("Local").centered()) + .block(Block::default().borders(Borders::NONE)), + Paragraph::new(Line::from("Network").centered()) + .block(Block::default().borders(Borders::NONE)), + ]; + for (title, area) in zip(titles, [left_title, right_title]) { + frame.render_widget(title, area); + } + + // WIM Info + if let Ok(wim_sources) = self.wim_sources.lock() { + // Local + let mut left_list = Vec::new(); + if wim_sources.thread_local.is_some() { + left_list.push(ListItem::new("Scanning...")); + } else { + left_list.extend( + wim_sources + .local + .iter() + .map(|wimfile| ListItem::new(format!("{}\n\n", wimfile.path))), + ); + } + let left_list = List::new(left_list).block( + Block::default() + .borders(Borders::ALL) + .padding(Padding::new(1, 1, 1, 1)), + ); + frame.render_widget(left_list, left_body); + + // Network + let mut right_list = Vec::new(); + if wim_sources.thread_network.is_some() { + right_list.push(ListItem::new("Scanning...")); + } else { + right_list.extend(wim_sources.network.iter().map(|wimfile| { + ListItem::new(format!( + "{}\n\n", + wimfile.path.split("\\").last().unwrap_or(&wimfile.path) + )) + })); + } + let right_list = List::new(right_list).block( + Block::default() + .borders(Borders::ALL) + .padding(Padding::new(1, 1, 1, 1)), + ); + frame.render_widget(right_list, right_body); + } + + // Done + Ok(()) + } +} diff --git a/win_installer/src/main.rs b/win_installer/src/main.rs new file mode 100644 index 0000000..8d21810 --- /dev/null +++ b/win_installer/src/main.rs @@ -0,0 +1,36 @@ +// 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 clap::Parser; +use color_eyre::Result; + +use crate::app::App; + +mod app; +mod components; +mod net; +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/net.rs b/win_installer/src/net.rs new file mode 100644 index 0000000..21990de --- /dev/null +++ b/win_installer/src/net.rs @@ -0,0 +1,69 @@ +// 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/>. +// + +//#![windows_subsystem = "windows"] +use std::ffi::CString; + +use windows_sys::Win32::Foundation::NO_ERROR; +use windows_sys::Win32::NetworkManagement::WNet; + +fn to_cstr(s: &str) -> CString { + CString::new(s).unwrap() +} + +pub fn connect_network_share( + server: &str, + share: &str, + username: &str, + password: &str, +) -> Result<(), u32> { + let remote_name = to_cstr(&format!("\\\\{server}\\{share}")); + + // init resources + let mut resources = WNet::NETRESOURCEA { + dwDisplayType: WNet::RESOURCEDISPLAYTYPE_SHAREADMIN, + dwScope: WNet::RESOURCE_GLOBALNET, + dwType: WNet::RESOURCETYPE_DISK, + dwUsage: WNet::RESOURCEUSAGE_ALL, + lpComment: std::ptr::null_mut(), + lpLocalName: std::ptr::null_mut(), // PUT a volume here if you want to mount as a windows volume + lpProvider: std::ptr::null_mut(), + lpRemoteName: remote_name.as_c_str().as_ptr() as *mut u8, + }; + + let username = format!("{server}\\{username}"); + let username = to_cstr(&username); + let password = to_cstr(password); + + // mount + let result = unsafe { + let username_ptr = username.as_ptr(); + let password_ptr = password.as_ptr(); + WNet::WNetAddConnection2A( + &mut resources as *mut WNet::NETRESOURCEA, + password_ptr as *const u8, + username_ptr as *const u8, + //WNet::CONNECT_INTERACTIVE, // Interactive will show a system dialog in case credentials are wrong to retry with the password. Put 0 if you don't want it + 0, + ) + }; + + if result == NO_ERROR { + Ok(()) + } else { + Err(result) + } +} diff --git a/win_installer/src/state.rs b/win_installer/src/state.rs new file mode 100644 index 0000000..fea2a26 --- /dev/null +++ b/win_installer/src/state.rs @@ -0,0 +1,215 @@ +// 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::{ + fs::read_dir, + sync::{Arc, Mutex}, +}; + +use core::{ + config::Config, + system::{ + disk::{Disk, PartitionTableType}, + drivers, + }, +}; + +use crate::{ + net::connect_network_share, + wim::{WimFile, WimSources, parse_wim_file}, +}; + +pub enum ScanType { + GeneralWimFiles, // Includes Windows installer WIMs + WindowsInstallers, +} + +#[derive(Debug, Default)] +pub struct State { + pub config: Config, + pub disk_index_dest: Option<usize>, + pub disk_list: Arc<Mutex<Vec<Disk>>>, + pub driver: Option<drivers::Driver>, + pub driver_list: Vec<drivers::Driver>, + pub table_type: Option<PartitionTableType>, + pub username: Option<String>, + pub wim_file_index: Option<usize>, + pub wim_image_index: Option<usize>, + pub wim_sources: Arc<Mutex<WimSources>>, +} + +impl State { + pub fn new(config: Config, disk_list: Arc<Mutex<Vec<Disk>>>) -> Self { + let wim_sources = Arc::new(Mutex::new(WimSources::new())); + State { + config, + disk_list, + wim_sources, + ..Default::default() + } + } + + pub fn reset_all(&mut self) { + self.wim_file_index = None; + self.wim_image_index = None; + if let Ok(mut sources) = self.wim_sources.lock() { + sources.reset_all(); + } + } + + pub fn reset_local(&mut self) { + if let Ok(mut sources) = self.wim_sources.lock() { + sources.reset_local(); + } + } + + pub fn reset_network(&mut self) { + if let Ok(mut sources) = self.wim_sources.lock() { + sources.reset_network(); + } + } + + pub fn scan_drivers(&mut self) { + self.driver_list = drivers::scan(); + } + + pub fn scan_wim_local(&mut self, scan_type: ScanType) { + let disk_list_arc = self.disk_list.clone(); + let wim_sources_arc = self.wim_sources.clone(); + let wim_sources_arc_inner = self.wim_sources.clone(); + if let Ok(mut wim_sources) = wim_sources_arc.lock() + && wim_sources.thread_local.is_none() + { + wim_sources.thread_local = Some(tokio::task::spawn(async move { + scan_local_drives(disk_list_arc, wim_sources_arc_inner, scan_type); + })); + } + } + + pub fn scan_wim_network(&mut self) { + let wim_sources_arc = self.wim_sources.clone(); + let wim_sources_arc_inner = self.wim_sources.clone(); + if let Ok(mut wim_sources) = wim_sources_arc.lock() + && wim_sources.thread_network.is_none() + { + let config = self.config.clone(); + wim_sources.thread_network = Some(tokio::task::spawn(async move { + scan_network_share(config, wim_sources_arc_inner); + })); + } + } +} + +fn get_subfolders(path_str: &str) -> Vec<String> { + if let Ok(read_dir) = read_dir(path_str) { + read_dir + .filter_map(|item| item.ok()) + .map(|item| item.path().to_string_lossy().into_owned()) + .collect() + } else { + // TODO: Use better error handling here? + Vec::new() + } +} + +pub fn scan_local_drives( + disk_list_arc: Arc<Mutex<Vec<Disk>>>, + wim_sources_arc: Arc<Mutex<WimSources>>, + scan_type: ScanType, +) { + let mut to_check: Vec<String> = Vec::new(); + let mut wim_files: Vec<WimFile> = Vec::new(); + + // Get drive letters + if let Ok(disk_list) = disk_list_arc.lock() { + disk_list.iter().for_each(|d| { + d.parts.iter().for_each(|p| { + if !p.letter.is_empty() { + match scan_type { + ScanType::GeneralWimFiles => { + to_check.append(&mut get_subfolders(&format!("{}:\\", &p.letter))); + } + ScanType::WindowsInstallers => { + to_check.push(format!("{}:\\Images", &p.letter)); + } + } + } + }); + }) + } + + // Scan drives + to_check.iter().for_each(|scan_path| { + let is_backup = !scan_path.ends_with("\\Images"); + if let Ok(read_dir) = read_dir(scan_path) { + read_dir.for_each(|item| { + if let Ok(item) = item + && item.file_name().to_string_lossy().ends_with(".wim") + && let Some(path_str) = item.path().to_str() + && let Ok(new_source) = parse_wim_file(path_str, is_backup) + { + wim_files.push(new_source); + } + }); + } + }); + + // Done + wim_files.sort(); + if let Ok(mut wim_sources) = wim_sources_arc.lock() { + wim_files + .into_iter() + .for_each(|file| wim_sources.add_local(file)); + } +} + +pub fn scan_network_share(config: Config, wim_sources_arc: Arc<Mutex<WimSources>>) { + let result = connect_network_share( + &config.network_server, + &config.network_share, + &config.network_user, + &config.network_pass, + ); + let mut wim_files: Vec<WimFile> = Vec::new(); + + // Connect to share + if result.is_err() { + return; + } + + // Scan share + let share_dir = format!("\\\\{}\\{}", &config.network_server, &config.network_share); + if let Ok(read_dir) = read_dir(share_dir) { + read_dir.for_each(|item| { + if let Ok(item) = item + && item.file_name().to_string_lossy().ends_with(".wim") + && let Some(path_str) = item.path().to_str() + && let Ok(new_source) = parse_wim_file(path_str, false) + // Assuming all network sources are installers + { + wim_files.push(new_source); + } + }); + } + + // Done + wim_files.sort(); + if let Ok(mut wim_sources) = wim_sources_arc.lock() { + wim_files + .into_iter() + .for_each(|file| wim_sources.add_network(file)); + } +} diff --git a/win_installer/src/wim.rs b/win_installer/src/wim.rs new file mode 100644 index 0000000..433d0aa --- /dev/null +++ b/win_installer/src/wim.rs @@ -0,0 +1,336 @@ +// 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::{ + cmp::Ordering, + collections::HashMap, + env, fmt, + fs::File, + io::BufReader, + path::{Path, PathBuf}, + process::Command, + sync::LazyLock, +}; + +use tempfile::NamedTempFile; +use tokio::task::JoinHandle; +use xml::reader::{EventReader, XmlEvent}; + +use core::system::disk::bytes_to_string; + +const UNATTEND_XML: &str = include_str!("../../config/unattend.xml"); + +static WIMINFO_EXE: LazyLock<String> = LazyLock::new(|| { + let program_files = + PathBuf::from(env::var("PROGRAMFILES").expect("Failed to resolve %PROGRAMFILES%")); + program_files + .join("wimlib/wiminfo.cmd") + .to_string_lossy() + .into_owned() +}); + +static WIN_BUILDS: LazyLock<HashMap<&str, &str>> = 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(Clone, Debug)] +pub struct WimFile { + pub path: String, + pub images: Vec<WimImage>, + pub is_backup: bool, +} + +impl WimFile { + pub fn summary(&self) -> String { + let mut s = format!("{self}"); + self.images.iter().for_each(|image| { + let image = format!("\n\t\t{image}"); + s.push_str(&image); + }); + + s + } +} + +impl fmt::Display for WimFile { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.path.split("\\").last().unwrap_or(&self.path)) + } +} + +impl PartialEq for WimFile { + fn eq(&self, other: &Self) -> bool { + self.path == other.path + } +} + +impl Eq for WimFile {} + +impl Ord for WimFile { + fn cmp(&self, other: &Self) -> Ordering { + self.path.cmp(&other.path) + } +} + +impl PartialOrd for WimFile { + fn partial_cmp(&self, other: &Self) -> Option<Ordering> { + Some(self.cmp(other)) + } +} + +#[derive(Clone, Debug, Default)] +pub struct WimImage { + pub build: String, + pub index: String, + pub name: String, + pub size: u64, + pub spbuild: String, + pub 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, + bytes_to_string(self.size) + ) + } +} + +#[derive(Debug, Default)] +pub struct WimSources { + pub local: Vec<WimFile>, + pub network: Vec<WimFile>, + pub thread_local: Option<JoinHandle<()>>, + pub thread_network: Option<JoinHandle<()>>, +} + +impl WimSources { + pub fn new() -> Self { + Default::default() + } + + pub fn add_local(&mut self, wim_file: WimFile) { + self.local.push(wim_file); + } + + pub fn add_network(&mut self, wim_file: WimFile) { + self.network.push(wim_file); + } + + pub fn get_file(&self, index: usize) -> WimFile { + let rel_index: usize; + let num_local = self.local.len(); + let mut use_local = true; + if index < num_local { + rel_index = index; + } else { + rel_index = index - num_local; + use_local = false; + }; + if use_local { + self.local.get(rel_index).unwrap().clone() + } else { + self.network.get(rel_index).unwrap().clone() + } + } + + pub fn get_file_list(&self) -> Vec<WimFile> { + let mut list = self.local.clone(); + list.append(&mut self.network.clone()); + list + } + + pub fn poll(&mut self) { + let thread = self.thread_local.take(); + if let Some(local) = thread + && !local.is_finished() + { + // Task still going, keep tracking + self.thread_local = Some(local); + } + let thread = self.thread_network.take(); + if let Some(network) = thread + && !network.is_finished() + { + // Task still going, keep tracking + self.thread_network = Some(network); + } + } + + pub fn reset_all(&mut self) { + self.local.clear(); + self.network.clear(); + } + + pub fn reset_local(&mut self) { + self.local.clear(); + } + + pub fn reset_network(&mut self) { + self.network.clear(); + } +} + +pub fn gen_unattend_xml(username: &str) -> String { + UNATTEND_XML.replace("NEWUSERNAME", username) +} + +fn get_wim_xml(wim_file: &str) -> std::io::Result<File> { + let tmp_file = NamedTempFile::new()?; + let _ = Command::new(&*WIMINFO_EXE) + .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) +} + +pub fn parse_wim_file(wim_file: &str, is_backup: bool) -> std::io::Result<WimFile> { + let mut wim_images: Vec<WimImage> = Vec::new(); + if !Path::new(wim_file).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); + for e in parser { + match e { + Ok(XmlEvent::StartElement { + name, attributes, .. + }) => { + 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(); + } + if current_element == "NAME" { + image.name = char_data.trim().to_string(); + } + if current_element == "SPBUILD" { + image.spbuild = char_data.trim().to_string(); + } + if current_element == "TOTALBYTES" { + let result = char_data.trim().parse::<u64>(); + if let Ok(size) = result { + image.size = size; + } + } + } + Ok(XmlEvent::EndElement { name }) => { + if name.local_name.to_uppercase() == "IMAGE" { + if image.size == 0 { + break; + } + // Append image to list + if image.build.is_empty() { + image.build.push('?'); + } + if image.spbuild.is_empty() { + image.spbuild.push('?'); + } + if !image.name.is_empty() && !image.index.is_empty() { + wim_images.push(image.clone()); + } + + // Reset image + image.reset() + } + } + Err(_) => { + break; + } + _ => {} + } + } + let wim_file = WimFile { + path: wim_file.to_string(), + images: wim_images, + is_backup, + }; + + Ok(wim_file) +}