feat: init; basic app render loop

This commit is contained in:
Erica Marigold 2025-05-02 08:30:09 +01:00
commit 17b102aac1
Signed by: DevComp
SSH key fingerprint: SHA256:jD3oMT4WL3WHPJQbrjC3l5feNCnkv7ndW8nYaHX5wFw
10 changed files with 5167 additions and 0 deletions

31
.gitignore vendored Executable file
View file

@ -0,0 +1,31 @@
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# Logs
*.log
# Binaries
*.exe
*.dll
*.so
*.dylib
*.lib
*.a
*.obj
*.o
*.bin
# RustRover
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

4538
Cargo.lock generated Executable file

File diff suppressed because it is too large Load diff

41
Cargo.toml Executable file
View file

@ -0,0 +1,41 @@
[package]
name = "pbt-rs"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "PortableBuildTools"
path = "src/main.rs"
[dependencies]
### CLI
better-panic = "0.3.0"
color-eyre = "0.6.3"
human-panic = "2.0.2"
strip-ansi-escapes = "0.2.1"
tracing = "0.1.41"
tracing-error = "0.2.1"
tracing-subscriber = { version = "0.3.19", features = ["env-filter", "serde"] }
### GUI
eframe = "0.31.1"
egui = "0.31.1"
epaint = "0.31.1"
### WINAPI
windows = { version = "0.61.1", features = [
"Win32_System_Console",
"Win32_System_Threading",
"Win32_UI_WindowsAndMessaging",
"Win32_UI_Shell",
"Win32_System_Registry",
"Win32_Security",
] }
windows-result = "0.3.2"
### MISC
libc = "0.2.172"
strum = "0.27.1"
strum_macros = "0.27.1"
lazy_static = "1.5.0"

226
src/app.rs Executable file
View file

@ -0,0 +1,226 @@
use std::{fmt::Display, string::ToString, sync::Arc};
use arch::Arch;
use choice::ChoiceOption;
use egui::{Galley, Rect, Ui};
pub(crate) mod arch;
#[macro_use]
pub(crate) mod choice;
#[derive(Debug, Clone)]
pub struct App {
env_setup_level: EnvSetupLevel,
preview_channel: bool,
msvc_version: String,
sdk_version: String,
host_arch: String,
target_arch: String,
install_path: String,
}
impl Default for App {
fn default() -> Self {
Self {
env_setup_level: EnvSetupLevel::default(),
preview_channel: bool::default(),
msvc_version: String::default(),
sdk_version: String::default(),
host_arch: Arch::default().to_string(),
target_arch: Arch::default().to_string(),
install_path: String::default(),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub enum EnvSetupLevel {
#[default]
None,
User,
Global,
}
impl EnvSetupLevel {
pub const ALL: [Self; 3] = [Self::None, Self::User, Self::Global];
}
impl Display for EnvSetupLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let option_strs = match self {
&Self::None => "Create environment setup scripts",
&Self::User => "Add to user environment",
&Self::Global => "Add to global environment",
};
f.write_str(option_strs)
}
}
impl App {
fn env_setup_selection(&mut self, ui: &mut Ui) {
egui::ComboBox::from_label(String::new())
.width(ui.available_width() / 1.5)
.selected_text(self.env_setup_level.to_string())
.show_ui(ui, |ui| {
EnvSetupLevel::ALL.iter().for_each(|opt| {
ui.selectable_value(&mut self.env_setup_level, *opt, format!("{}", opt));
});
});
}
fn selection_menu(
selected: &mut String,
ui: &mut Ui,
width: f32,
component: ChoiceOption<Vec<String>>,
) {
let name = component.to_string();
ui.label(name);
egui::ComboBox::from_id_salt(&component)
.width(width)
.selected_text(selected.clone()) // FIXME
.show_ui(ui, |ui| {
component.options().iter().for_each(|opt| {
ui.selectable_value(selected, opt.to_string(), opt.to_string());
});
});
}
fn boxed_inner(
ui: &mut Ui,
text: String,
box_rect: Rect,
text_rect: Rect,
galley: Arc<Galley>,
) {
let left = box_rect.left();
let right = box_rect.right();
let top = box_rect.top();
let bottom = box_rect.bottom();
let stroke = ui.style().visuals.window_stroke();
// Left side
ui.painter()
.line_segment([egui::pos2(left, top), egui::pos2(left, bottom)], stroke);
// Bottom side
ui.painter().line_segment(
[egui::pos2(left, bottom), egui::pos2(right, bottom)],
stroke,
);
// Right side
ui.painter()
.line_segment([egui::pos2(right, top), egui::pos2(right, bottom)], stroke);
// Top side
ui.painter().line_segment(
[egui::pos2(left, top), egui::pos2(text_rect.left(), top)],
stroke,
);
ui.painter().line_segment(
[
egui::pos2(text_rect.left() + galley.size().x, top),
egui::pos2(right, top),
],
stroke,
);
ui.painter().text(
text_rect.left_center(),
egui::Align2::LEFT_CENTER,
text,
ui.style().text_styles[&egui::TextStyle::Body].clone(),
ui.style().visuals.text_color(),
);
}
pub fn boxed<R>(ui: &mut Ui, heading: impl ToString, draw: impl FnOnce(&mut Ui) -> R) {
ui.vertical(|ui| {
let text = heading.to_string();
let margin = ui.spacing().window_margin;
let available_width = ui.available_width() - margin.left as f32 - margin.right as f32;
ui.horizontal(|ui| {
ui.add_space(margin.left as f32);
ui.vertical(|ui| {
ui.set_width(available_width);
egui::Frame::new()
.inner_margin(epaint::Margin::symmetric(10, 15))
.show(ui, draw);
let galley = ui.painter().layout_no_wrap(
text.clone(),
ui.style().text_styles[&egui::TextStyle::Body].clone(),
egui::Color32::LIGHT_GRAY, // TOOD: use something within defaults
);
let border_rect = ui.min_rect();
let text_rect = egui::Rect::from_min_size(
egui::pos2(
border_rect.left() + 8.0,
border_rect.top() - galley.size().y / 2.0,
),
egui::vec2(galley.size().x, galley.size().y),
);
Self::boxed_inner(ui, text, border_rect, text_rect, galley);
ui.add_space(margin.right as f32);
});
});
});
}
}
impl eframe::App for App {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ctx, |ui| {
ui.horizontal(|ui| {
self.env_setup_selection(ui);
ui.checkbox(&mut self.preview_channel, "Preview channel");
});
ui.add_space(15.0);
ui.horizontal(|ui| {
let width_per_choice = ctx.screen_rect().width() / 4.75;
#[rustfmt::skip]
let choices = vec![
(&mut self.msvc_version, ChoiceOption::MsvcVersion(choices!("12.3", "12.4", "12.4+extra"))),
(&mut self.sdk_version, ChoiceOption::SdkVersion(choices!("12.3", "12.4", "12.4+extra"))),
(&mut self.host_arch, ChoiceOption::HostArch(choices!(Arch::X86, Arch::X64))),
(&mut self.target_arch, ChoiceOption::TargetArch(choices!(Arch::X86, Arch::X64))),
];
for (selection, choice) in choices {
ui.vertical(|ui| {
Self::selection_menu(selection, ui, width_per_choice, choice.clone())
});
}
});
ui.add_space(25.0);
Self::boxed(ui, "Destination Folder", |ui| {
ui.horizontal(|ui| {
ui.horizontal_centered(|ui| {
let path_selection_box = egui::TextEdit::singleline(&mut self.install_path)
.hint_text("Select folder path");
ui.add(path_selection_box);
ui.button("Browse...")
});
});
});
ui.horizontal(|ui| {
ui.checkbox(&mut true, "I accept the License Agreement");
ui.add_space(ui.available_width() - 45.0);
if ui.button("Install").clicked() {
// pop out into console
}
});
});
}
}

20
src/app/arch.rs Executable file
View file

@ -0,0 +1,20 @@
use std::env::consts;
use strum_macros::{Display, EnumString};
#[derive(Debug, Clone, Copy, EnumString, Display)]
#[strum(serialize_all = "lowercase")]
pub enum Arch {
X86,
X64,
}
impl Default for Arch {
fn default() -> Self {
match consts::ARCH {
"x86" => Self::X86,
"x86_64" => Self::X64,
_ => unreachable!(),
}
}
}

42
src/app/choice.rs Executable file
View file

@ -0,0 +1,42 @@
use std::hash::Hash;
use strum_macros::Display;
#[macro_export]
macro_rules! choices {
($($choice:expr),*) => {
vec![$($choice),*]
.iter()
.map(|e| e.to_string())
.collect::<Vec<String>>()
};
}
#[derive(Debug, Clone, PartialEq, Display)]
pub enum ChoiceOption<T: Into<Vec<String>>> {
#[strum(to_string = "MSVC Version")]
MsvcVersion(T),
#[strum(to_string = "SDK Version")]
SdkVersion(T),
#[strum(to_string = "Host Arch")]
HostArch(T),
#[strum(to_string = "Target Arch")]
TargetArch(T),
}
impl<T: Into<Vec<String>>> ChoiceOption<T> {
pub fn options(self) -> Vec<String> {
match self {
Self::MsvcVersion(options)
| Self::SdkVersion(options)
| Self::HostArch(options)
| Self::TargetArch(options) => options.into(),
}
}
}
impl<T: Into<Vec<String>>> Hash for ChoiceOption<T> {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.to_string().hash(state);
}
}

95
src/console.rs Executable file
View file

@ -0,0 +1,95 @@
use std::{
env,
io::{self, ErrorKind},
os::windows::ffi::OsStrExt,
};
use tracing::instrument;
use windows::Win32::Foundation::{CloseHandle, GetLastError, HWND};
use windows::Win32::System::Console::GetConsoleWindow;
use windows::Win32::System::Threading::{
CreateProcessW, DETACHED_PROCESS, GetCurrentProcessId, PROCESS_INFORMATION, STARTUPINFOW,
};
use windows::Win32::UI::WindowsAndMessaging::GetWindowThreadProcessId;
use windows::core::{PCWSTR, PWSTR};
use windows_result::{Error, Result};
#[derive(Debug, Clone, Copy)]
pub enum ProcessMode {
Console(#[allow(dead_code)] HWND),
Gui,
}
impl ProcessMode {
#[instrument(level = "debug", name = "detect_process_mode" ret, err)] // FIXME: why no log???
pub fn from_current_process() -> Result<Self> {
unsafe {
let console = GetConsoleWindow();
if console.0 == std::ptr::null_mut() {
return Ok(ProcessMode::Gui);
}
let mut pid = [0u32; 1];
if GetWindowThreadProcessId(console, Some(pid.as_mut_ptr())) == 0 {
GetLastError().ok()?;
}
if GetCurrentProcessId() != pid[0] {
Ok(ProcessMode::Console(console))
} else {
Ok(ProcessMode::Gui)
}
}
}
#[instrument(level = "debug", name = "detach_to_gui", skip(self))]
pub fn detach_to_gui(self) -> Result<()> {
tracing::info!("Attempting to create a detached GUI process from the current process");
if !matches!(self, ProcessMode::Console(_)) {
return Err(Error::from(io::Error::new(
ErrorKind::Unsupported,
"Not running in console mode",
)));
}
let program = env::current_exe()?
.into_os_string()
.encode_wide()
.collect::<Vec<u16>>();
let mut command_line_raw = env::args().fold(program.clone(), |acc, e| {
let mut acc = acc;
acc.extend(format!(" {e}").encode_utf16());
acc
});
unsafe {
let mut startup_info = std::mem::zeroed::<STARTUPINFOW>();
let mut process_info = std::mem::zeroed::<PROCESS_INFORMATION>();
startup_info.cb = std::mem::size_of::<STARTUPINFOW>() as u32;
CreateProcessW(
PCWSTR::from_raw(
program
.iter()
.cloned()
.chain(std::iter::once(0))
.collect::<Vec<_>>()
.as_ptr(),
),
Some(PWSTR::from_raw(command_line_raw.as_mut_ptr())),
None,
None,
false,
DETACHED_PROCESS,
None,
None,
&mut startup_info,
&mut process_info,
)?;
CloseHandle(process_info.hProcess)?;
std::process::exit(0)
}
}
}

44
src/errors.rs Executable file
View file

@ -0,0 +1,44 @@
use std::env;
use color_eyre::Result;
use tracing::error;
pub fn init() -> Result<()> {
let (panic_hook, eyre_hook) = color_eyre::config::HookBuilder::default()
.panic_section(format!(
"This is a bug. Consider reporting it at {}",
env!("CARGO_PKG_REPOSITORY")
))
.capture_span_trace_by_default(false)
.display_location_section(false)
.display_env_section(false)
.into_hooks();
eyre_hook.install()?;
std::panic::set_hook(Box::new(move |panic_info| {
#[cfg(not(debug_assertions))]
{
use human_panic::{handle_dump, metadata, print_msg};
let metadata = metadata!();
let file_path = handle_dump(&metadata, panic_info);
// prints human-panic message
print_msg(file_path, &metadata)
.expect("human-panic: printing error message to console failed");
eprintln!("{}", panic_hook.panic_report(panic_info)); // prints color-eyre stack trace to stderr
}
let msg = format!("{}", panic_hook.panic_report(panic_info));
error!("Error: {}", strip_ansi_escapes::strip_str(msg));
#[cfg(debug_assertions)]
{
// Better Panic stacktrace that is only enabled when debugging.
better_panic::Settings::auto()
.most_recent_first(false)
.lineno_suffix(true)
.verbosity(better_panic::Verbosity::Full)
.create_panic_handler()(panic_info);
}
std::process::exit(libc::EXIT_FAILURE);
}));
Ok(())
}

86
src/logging.rs Executable file
View file

@ -0,0 +1,86 @@
use std::{env, io::stderr};
use color_eyre::{Result, eyre::eyre};
use tracing_error::ErrorLayer;
use tracing_subscriber::{EnvFilter, fmt, prelude::*, util::TryInitError};
use crate::console::ProcessMode;
lazy_static::lazy_static! {
pub static ref LOG_ENV: String = format!("{}_LOG", env!("CARGO_PKG_NAME").to_uppercase());
pub static ref LOG_FILE: String = format!("{}.log", env!("CARGO_PKG_NAME"));
}
pub fn init(process_mode: ProcessMode) -> Result<()> {
//
// File initialization
//
let directory = env::current_dir()?;
std::fs::create_dir_all(directory.clone())?;
let log_path = directory.join(LOG_FILE.clone());
let log_file = match process_mode {
ProcessMode::Console(_) => std::fs::File::open("NUL")?,
ProcessMode::Gui => std::fs::File::create(log_path)?,
};
//
// Filtering
//
// Stage 1: Construct base filter
let env_filter = EnvFilter::builder().with_default_directive(
if cfg!(debug_assertions) {
tracing::Level::DEBUG
} else {
tracing::Level::INFO
}
.into(),
);
// Stage 2: Attempt to read from {RUST|CRATE_NAME}_LOG env var or ignore
let env_filter = env_filter.try_from_env().unwrap_or_else(|_| {
env_filter
.with_env_var(LOG_ENV.to_string())
.from_env_lossy()
});
// Stage 3: Enable directives to reduce verbosity for release mode builds
#[cfg(not(debug_assertions))]
let env_filter = env_filter
.add_directive("egui=info".parse().unwrap())
.add_directive("eframe=info".parse().unwrap())
.add_directive("epaint=info".parse().unwrap())
.add_directive("windows=info".parse().unwrap());
//
// Subscription
//
// Build the subscriber and apply it
tracing_subscriber::registry()
.with(env_filter)
.with(
// Logging to file
fmt::layer()
.with_writer(log_file)
.with_target(true)
.with_ansi(false),
)
.with({
// Logging to stderr
let layer = fmt::layer()
.with_writer(stderr)
.with_timer(tracing_subscriber::fmt::time())
.with_ansi(true);
// Enable compact mode for release logs
#[cfg(not(debug_assertions))]
let layer = layer.compact();
layer
})
.with(ErrorLayer::default())
.try_init()
.map_err(|err: TryInitError| eyre!(err))
}

44
src/main.rs Executable file
View file

@ -0,0 +1,44 @@
use app::App;
use color_eyre::eyre;
use console::ProcessMode;
use eframe::NativeOptions;
use egui::ViewportBuilder;
mod app;
pub(crate) mod console;
mod errors;
mod logging;
fn main() -> eyre::Result<()> {
let process_mode = ProcessMode::from_current_process()?;
crate::errors::init()?;
crate::logging::init(process_mode)?;
tracing::info!(concat!(
env!("CARGO_PKG_NAME"),
" v",
env!("CARGO_PKG_VERSION")
));
match process_mode {
ProcessMode::Console(_) => process_mode.detach_to_gui()?,
ProcessMode::Gui => {
tracing::info!("Attempting to natively render UI");
eframe::run_native(
"portable-msvc-rs",
NativeOptions {
viewport: ViewportBuilder::default()
.with_resizable(false)
.with_inner_size(egui::vec2(420.0, 200.0))
.with_maximize_button(false)
.with_minimize_button(false),
..Default::default()
},
Box::new(|_cc| Ok(Box::new(App::default()))),
)
.unwrap();
}
}
Ok(())
}