Compare commits

..

No commits in common. "main" and "1.0.3" have entirely different histories.
main ... 1.0.3

27 changed files with 5 additions and 6876 deletions

3
.gitignore vendored
View File

@ -1,4 +1 @@
SinfarInstaller-v30.exe
win32_8181.7z
sinfar_all_files_v30.7z
rust/target

View File

Before

Width:  |  Height:  |  Size: 220 KiB

After

Width:  |  Height:  |  Size: 220 KiB

View File

@ -1,46 +1,5 @@
Requires
# Sinfar Installer
https://github.com/DigitalMediaServer/NSIS-INetC-plugin/releases
Thank you for interest in the SinfarInstaller, this installer works for both the EE and Diamond edition of Neverwinter Nights. Leave feedback in the issue section, which can also, as implied in the name, in the issue section.
For any question, you can ask FieryImp on the Sinfar Discord server.
## Feedback
Please give us feedback about your experience with the SinfarInstaller, whether you think something can be improved to give a smoother experience, or if you run into any kind of issue. Our goal is to make Sinfar as easy as possible to install for new players or old players installing on a new system.
You may leave feedback in the "Issue" section above on this website or contact FieryImp directly for immediate questions.
## For Version 2
### For regular users
Using the installer is easy, click on the download link below, download the file, unzip it, launch, and follow the instruction!
#### Download links
All files are hosted by us.
- [Download installer 2.0.0](https://git.sinfar.net/Fiery_Imp/sinfar-installer/releases/download/2.0.0/sinfar-installer.zip)
#### Anti-virus false positive
The download of the zip file and the unzipping of it seems to trigger false positive alert from windows defender sometimes. This doesn't always happen and I have to find the cause of it.
Windows Defender will identify a supposed virus as *Trojan:Script/Sabsik.FL.A!ml* . It's a well known error for installer programmed that are unsigned. We do not yet have means to sign the installer unfortunately as this could cost several hundred dollars per year.
Please let us know if you run into this issue, and we'll keep working on trying to get that false positive to stop showing up.
### For developers of the Installer
I'll try to list here what's needed here in order to compile the Installer.
#### Requirement
- Rust SDK [(Download here)](https://www.rust-lang.org/tools/install)
## For Version 1
All version of the installer starting by 1 are no longer supported, while still being available for download at the moment.
And nsis7z

4795
rust/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,31 +0,0 @@
[package]
name = "sinfar-installer"
version = "0.1.0"
edition = "2021"
authors = ["Your Name"]
description = "Sinfar installer application"
[dependencies]
eframe = "0.23.0"
rfd = "0.12.0" # For file dialogs
reqwest = { version = "0.11", features = ["blocking", "json", "stream"] }
tokio = { version = "1", features = ["full"] }
futures-util = "0.3"
sevenz-rust = "0.5.0" # For 7z extraction
dirs = "5.0" # For finding common directories
anyhow = "1.0" # Error handling
log = "0.4"
env_logger = "0.10"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
once_cell = "1.18" # For lazy statics
image = "0.24" # For icon handling
zip = "2.6.1"
# Add this to handle Windows-specific dependencies
[target.'cfg(windows)'.dependencies]
winreg = "0.51" # For Windows registry access
winapi = { version = "0.3.9", features = ["winuser", "windef", "commdlg", "shellapi"] }
[target.'cfg(unix)'.dependencies]
# Linux-specific dependencies if needed

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

View File

@ -1,397 +0,0 @@
use eframe::{egui, Frame};
use std::path::PathBuf;
use std::sync::Arc;
use crate::installer::validator::*;
use crate::installer::downloader::DownloadManager;
use crate::ui::download_progress::download_progress;
use crate::ui::game_selection::game_selection;
use crate::ui::installation_progress::installation_progress;
use crate::ui::path_selection::path_selection;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GameType {
Diamond,
EnhancedEdition,
}
#[derive(Debug, Clone, PartialEq)]
pub enum InstallerState {
GameSelection,
PathSelection,
Download,
Installing,
Complete,
}
#[derive(Default)]
pub struct DownloadState {
pub progress: f32,
pub speed: f64,
pub estimated_remaining: std::time::Duration,
pub completed: bool,
pub files: Option<Vec<PathBuf>>,
pub error: Option<String>,
pub cancelled: bool,
}
pub struct SinfarInstallerApp<> {
pub state: InstallerState,
pub game_type: Option<GameType>,
pub install_path: Option<PathBuf>,
pub ee_exe_path: Option<PathBuf>,
pub download_progress: f32,
pub download_speed: f64,
pub estimated_remaining: std::time::Duration,
pub extraction_progress: f32,
pub download_error: Option<String>,
pub install_error: Option<String>,
pub download_state: std::sync::Arc<std::sync::Mutex<DownloadState>>,
pub eframe_ctx: Option<egui::Context>,
pub runtime: Arc<tokio::runtime::Runtime>
}
impl SinfarInstallerApp<> {
pub fn new(_cc: &eframe::CreationContext<>, runtime: Arc<tokio::runtime::Runtime>) -> Self {
Self {
state: InstallerState::GameSelection,
game_type: Some(GameType::Diamond),
install_path: None,
ee_exe_path: None,
download_progress: 0.0,
download_speed: 0.0,
estimated_remaining: std::time::Duration::new(0, 0),
extraction_progress: 0.0,
download_error: None,
install_error: None,
download_state: std::sync::Arc::new(std::sync::Mutex::new(DownloadState::default())),
eframe_ctx: None,
runtime, // Store the runtime
}
}
fn next_state(&mut self) {
self.state = match self.state {
InstallerState::GameSelection => InstallerState::PathSelection,
InstallerState::PathSelection => InstallerState::Download,
InstallerState::Download => InstallerState::Installing,
InstallerState::Installing => InstallerState::Complete,
InstallerState::Complete => return,
};
}
fn previous_state(&mut self) {
self.state = match self.state {
InstallerState::GameSelection => return,
InstallerState::PathSelection => InstallerState::GameSelection,
InstallerState::Download => InstallerState::PathSelection,
InstallerState::Installing => return, // Can't go back during installation
InstallerState::Complete => return,
};
}
}
impl<> eframe::App for SinfarInstallerApp<> {
fn update(&mut self, ctx: &egui::Context, _frame: &mut Frame) {
self.eframe_ctx = Some(ctx.clone());
if self.state == InstallerState::Download {
if let Ok(state) = self.download_state.lock() {
// Set progress to 100% if completed
if state.completed {
self.download_progress = 1.0;
self.download_speed = 0.0;
self.estimated_remaining = std::time::Duration::from_secs(0);
} else if !state.cancelled {
self.download_progress = state.progress;
self.download_speed = state.speed;
self.estimated_remaining = state.estimated_remaining;
}
if let Some(error) = &state.error {
self.download_error = Some(error.clone());
}
}
} else if self.state == InstallerState::Installing {
// Sync extraction progress from shared state
if let Ok(state) = self.download_state.lock() {
self.extraction_progress = state.progress;
if let Some(error) = &state.error {
self.install_error = Some(error.clone());
}
}
}
egui::CentralPanel::default().show(ctx, |ui| {
ui.vertical_centered(|ui| {
ui.heading("Sinfar NWN Custom Content Installer");
ui.add_space(10.0);
match self.state {
InstallerState::GameSelection => {
game_selection::render(ui, self);
},
InstallerState::PathSelection => {
path_selection::render(ui, self);
},
InstallerState::Download => {
download_progress::render(ui, self);
},
InstallerState::Installing => {
installation_progress::render(ui, self);
},
InstallerState::Complete => {
ui.label("Installation complete!");
ui.label("Launch the game from the desktop shortcut to start playing.");
if ui.button("Close").clicked() {
std::process::exit(0);
}
},
}
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::RIGHT), |ui| {
ui.horizontal(|ui| {
let can_continue = match self.state {
InstallerState::GameSelection => self.game_type.is_some(),
InstallerState::PathSelection => {
match self.game_type {
Some(GameType::Diamond) => validator::validate_diamond_path(self.install_path.as_ref().unwrap()),
Some(GameType::EnhancedEdition) =>{
validator::validate_ee_path(self.install_path.as_ref().unwrap()) &&
validator::validate_ee_exe_path(self.ee_exe_path.as_ref().unwrap())
},
None => false,
}
},
InstallerState::Download => {
if let Ok(state) = self.download_state.lock() {
state.completed && state.files.is_some()
} else {
false
}
},
_ => true,
};
if self.state != InstallerState::Installing && self.state != InstallerState::Complete {
if ui.add_enabled(can_continue, egui::Button::new("Next")).clicked() {
self.next_state();
if self.state == InstallerState::Download {
self.start_download();
} else if self.state == InstallerState::Installing {
//self.start_extract();
self.start_installation();
}
}
let can_go_back = match self.state {
InstallerState::Download => {
if let Ok(state) = self.download_state.lock() {
state.progress == 0.0 || state.completed || state.error.is_some()
} else {
true
}
},
InstallerState::GameSelection => false,
_ => true
};
if ui.add_enabled(can_go_back, egui::Button::new("Back")).clicked() {
self.previous_state();
}
}
});
});
});
}
}
// Implementation of key installer functions
impl SinfarInstallerApp<> {
pub fn start_download(&mut self) {
// Reset progress and errors
self.download_progress = 0.0;
self.download_speed = 0.0;
self.estimated_remaining = std::time::Duration::from_secs(0);
self.download_error = None;
// Initialize download state
let download_state = std::sync::Arc::new(std::sync::Mutex::new(DownloadState::default()));
self.download_state = download_state.clone();
// Clone the game type for the async closure
let game_type = self.game_type.clone().unwrap();
// Create a context handle for requesting UI updates
let ctx = eframe::egui::Context::clone(self.eframe_ctx.as_ref().unwrap());
let repaint_ctx_progress = ctx.clone();
let repaint_ctx_complete = ctx.clone();
// Spawn the download task
self.runtime.spawn(async move {
// Create a download manager with progress callback
let progress_state = download_state.clone();
let manager = DownloadManager::new(move |progress, _file, speed, remaining| {
// Update progress through shared state
if let Ok(mut state) = progress_state.lock() {
state.progress = progress;
state.speed = speed;
state.estimated_remaining = remaining;
repaint_ctx_progress.request_repaint();
}
});
// Start the download process
match manager.download_all_files(&game_type).await {
Ok(files) => {
// Mark download as complete with the files
if let Ok(mut state) = download_state.lock() {
state.completed = true;
state.files = Some(files);
repaint_ctx_complete.request_repaint();
}
}
Err(e) => {
// Record the error
if let Ok(mut state) = download_state.lock() {
state.error = Some(format!("Download failed: {}", e));
ctx.request_repaint();
}
}
}
});
}
pub fn cancel_download(&mut self) {
if let Ok(mut state) = self.download_state.lock() {
state.error = Some("Download cancelled".to_string());
state.progress = 0.0;
state.cancelled = true;
state.speed = 0.0;
state.estimated_remaining = std::time::Duration::from_secs(0);
}
self.download_progress = 0.0;
self.download_speed = 0.0;
self.estimated_remaining = std::time::Duration::from_secs(0);
}
pub fn start_installation(&mut self) {
// Reset state
if let Ok(mut state) = self.download_state.lock() {
state.progress = 0.0;
state.completed = false;
state.error = None;
state.cancelled = false;
}
let install_path = self.install_path.clone().expect("Install path should be set");
let ee_exe_path = self.ee_exe_path.clone();
let game_type = self.game_type.clone().expect("Game type should be set");
let download_state = self.download_state.clone();
let progress_state = self.download_state.clone();
let eframe_ctx = self.eframe_ctx.clone().expect("eframe context should be set");
let runtime = self.runtime.clone();
// Create extraction manager with progress callback
let extractor = crate::installer::extractor::ExtractionManager::new(move |progress: f32, _filename: &str, remaining: std::time::Duration| {
if let Ok(mut app_state) = progress_state.lock() {
app_state.progress = progress;
app_state.estimated_remaining = remaining;
eframe_ctx.request_repaint();
}
});
// Get downloaded files before spawning the async task
let downloaded_files = if let Ok(state) = download_state.lock() {
state.files.clone()
} else {
None
};
if let Some(files) = downloaded_files {
let download_state = download_state.clone();
runtime.spawn(async move {
// Try to install the files
if let Err(e) = extractor.install_all_files(
&files,
&install_path,
&game_type,
ee_exe_path.as_deref()
).await {
if let Ok(mut app_state) = download_state.lock() {
app_state.error = Some(format!("Installation failed: {}", e));
}
} else {
// Create shortcut after successful installation
let shortcut_result = match game_type {
GameType::Diamond => {
// For Diamond, point to sinfarx.exe in install path
let target = install_path.join("sinfarx.exe");
crate::installer::shortcut::create_shortcut(&target, "Sinfar", None)
},
GameType::EnhancedEdition => {
// For EE, point to sinfarx_ee.exe in bin/win32_8181
if let Some(ee_path) = ee_exe_path {
let target = ee_path.join("bin").join("win32_8181").join("sinfarx_ee.exe");
crate::installer::shortcut::create_shortcut(&target, "Sinfar EE", None)
} else {
Ok(()) // Should never happen as we validate earlier
}
}
};
if let Err(e) = shortcut_result {
if let Ok(mut app_state) = download_state.lock() {
app_state.error = Some(format!("Failed to create shortcut: {}", e));
}
} else {
// Mark as complete on success
if let Ok(mut app_state) = download_state.lock() {
app_state.completed = true;
app_state.progress = 1.0;
}
}
}
});
}
}
pub fn start_extract(&mut self) {
let dearchiver = crate::installer::SevenZDearchiver::new();
let download_state = self.download_state.clone();
let error_state = download_state.clone();
let eframe_ctx = self.eframe_ctx.clone().expect("eframe context should be set");
// Placeholder paths for testing
let archive_path = std::path::PathBuf::from("C:/Users/Samuel/AppData/Local/Temp/sinfar_installer/sinfar_all_files_v30.7z");
let output_dir = std::path::PathBuf::from("C:/Users/Samuel/AppData/Local/Temp/sinfar_installer/test");
self.runtime.spawn(async move {
if let Err(e) = dearchiver.extract(
&archive_path,
&output_dir,
move |progress, eta| {
if let Ok(mut state) = download_state.lock() {
state.progress = progress;
state.estimated_remaining = eta;
if state.progress == 1.0 {
state.completed = true;
}
else {
state.completed = false;
}
eframe_ctx.request_repaint();
}
}
).await {
if let Ok(mut state) = error_state.lock() {
state.error = Some(format!("Extraction failed: {}", e));
}
}
});
}
}

View File

@ -1,112 +0,0 @@
use anyhow::Result;
use std::path::Path;
use std::sync::Arc;
use tokio::sync::{Mutex, watch};
use std::io::{Read, Seek};
use std::fs;
use std::time::{Duration, Instant};
pub struct SevenZDearchiver {
cancel_tx: watch::Sender<bool>,
cancel_rx: watch::Receiver<bool>,
}
impl SevenZDearchiver {
pub fn new() -> Self {
let (cancel_tx, cancel_rx) = watch::channel(false);
Self {
cancel_tx,
cancel_rx,
}
}
pub fn cancel(&self) {
let _ = self.cancel_tx.send(true);
}
pub async fn extract<F>(&self, archive_path: &Path, output_dir: &Path, progress_callback: F) -> Result<()>
where
F: Fn(f32, Duration) + Send + Sync + 'static
{
// Create wrapper for progress tracking
struct ProgressReader<R: Read + Seek> {
inner: R,
bytes_read: u64,
total_size: u64,
last_update: Instant,
cancel_signal: watch::Receiver<bool>,
progress_callback: Arc<Box<dyn Fn(f32, Duration) + Send + Sync>>,
}
impl<R: Read + Seek> Read for ProgressReader<R> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
// Check cancellation
if *self.cancel_signal.borrow() {
return Ok(0); // Return 0 to indicate EOF and stop extraction
}
let bytes = self.inner.read(buf)?;
self.bytes_read += bytes as u64;
let now = Instant::now();
let elapsed = now.duration_since(self.last_update);
// Update progress every 100ms
if elapsed.as_millis() > 100 {
let progress = self.bytes_read as f32 / self.total_size as f32;
// Calculate estimated time remaining
let speed = self.bytes_read as f64 / elapsed.as_secs_f64();
let remaining_bytes = self.total_size - self.bytes_read;
let eta = if speed > 0.0 {
Duration::from_secs_f64(remaining_bytes as f64 / speed)
} else {
Duration::from_secs(0)
};
(self.progress_callback)(progress, eta);
self.last_update = now;
}
Ok(bytes)
}
}
impl<R: Read + Seek> Seek for ProgressReader<R> {
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
self.inner.seek(pos)
}
}
// Start extraction in blocking task
let cancel_rx = self.cancel_rx.clone();
let progress_cb = Arc::new(Box::new(progress_callback) as Box<dyn Fn(f32, Duration) + Send + Sync>);
let archive_path = archive_path.to_owned();
let output_dir = output_dir.to_owned();
tokio::task::spawn_blocking(move || -> Result<()> {
let file = fs::File::open(&archive_path)?;
let file_size = file.metadata()?.len();
let progress_reader = ProgressReader {
inner: file,
bytes_read: 0,
total_size: file_size,
last_update: Instant::now(),
cancel_signal: cancel_rx,
progress_callback: progress_cb,
};
sevenz_rust::decompress(progress_reader, &output_dir)?;
Ok(())
}).await??;
Ok(())
}
}
impl Default for SevenZDearchiver {
fn default() -> Self {
Self::new()
}
}

View File

@ -1,300 +0,0 @@
// src/installer/downloader.rs
use anyhow::{Result, Context, anyhow};
use reqwest::Client;
use tokio::io::AsyncWriteExt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use futures_util::StreamExt;
use tokio::sync::Mutex;
use std::fs::metadata;
use reqwest::header::{HeaderMap, HeaderValue, RANGE, USER_AGENT};
use log::{info, warn};
use crate::app::GameType;
pub struct DownloadManager {
client: Client,
progress_callback: Arc<Mutex<Box<dyn Fn(f32, &str, f64, Duration) + Send + Sync>>>,
temp_dir: PathBuf,
}
impl DownloadManager {
pub fn new<F>(progress_callback: F) -> Self
where
F: Fn(f32, &str, f64, Duration) + Send + Sync + 'static
{
let client = Client::builder()
.timeout(Duration::from_secs(1800))
.build()
.unwrap_or_default();
let temp_dir = std::env::temp_dir().join("sinfar_installer");
println!("Files will be downloaded to: {}", temp_dir.display());
// Ensure temp directory exists
if !temp_dir.exists() {
if let Err(e) = std::fs::create_dir_all(&temp_dir) {
warn!("Failed to create temp directory: {}", e);
}
}
Self {
client,
progress_callback: Arc::new(Mutex::new(Box::new(progress_callback))),
temp_dir,
}
}
pub fn set_progress_callback<F>(&mut self, callback: F)
where
F: Fn(f32, &str, f64, Duration) + Send + Sync + 'static,
{
self.progress_callback = Arc::new(Mutex::new(Box::new(callback)));
}
/// Downloads a file with resume capability and progress tracking
pub async fn download_file(&self, url: &str, output_path: &Path) -> Result<()> {
info!("Starting download of {} to {}", url, output_path.display());
// Create parent directories if they don't exist
if let Some(parent) = output_path.parent() {
if !parent.exists() {
tokio::fs::create_dir_all(parent).await?;
}
}
// Prepare headers for potential resume
let mut headers = HeaderMap::new();
headers.insert(USER_AGENT, HeaderValue::from_static("Sinfar NWN Installer"));
// First make a HEAD request to get the total file size
let response_head = self.client.get(url)
.headers(headers)
.send()
.await
.context("Failed to send HTTP request")?;
let status = response_head.status();
// Get content length for progress calculation
let total_size = response_head.content_length().unwrap_or(0);
let content_length = if status.as_u16() == 206 {
if let Some(content_range) = response_head.headers().get("content-range") {
let range_str = content_range.to_str().unwrap_or("");
if let Some(size_part) = range_str.split('/').nth(1) {
size_part.parse::<u64>().unwrap_or(0)
} else {
total_size
}
} else {
total_size
}
} else {
response_head.content_length().unwrap_or(total_size)
};
// Check if we have an existing file and its size
let mut downloaded_size: u64 = 0;
if output_path.exists() {
let metadata = tokio::fs::metadata(output_path).await?;
downloaded_size = metadata.len();
// If the file size matches exactly, we're done
if content_length > 0 && downloaded_size == content_length {
info!("File is already completely downloaded: {} bytes", content_length);
// Call progress callback with 100%
let progress_callback = self.progress_callback.clone();
let filename = output_path.file_name()
.and_then(|f| f.to_str())
.unwrap_or("file");
let callback = progress_callback.lock().await;
callback(1.0, filename, 0.0, Duration::from_secs(0));
return Ok(());
} else if downloaded_size > content_length {
// If local file is larger than remote, start fresh
info!("Local file size mismatch ({} > {}), starting fresh download", downloaded_size, content_length);
downloaded_size = 0;
}
}
// Prepare headers for potential resume
let mut headers_toresume = HeaderMap::new();
headers_toresume.insert(USER_AGENT, HeaderValue::from_static("Sinfar NWN Installer"));
if downloaded_size > 0 {
info!("Attempting to resume from byte {}", downloaded_size);
headers_toresume.insert(RANGE, HeaderValue::from_str(&format!("bytes={}-", downloaded_size))?);
}
//Make the actual download request
let response = self.client.get(url)
.headers(headers_toresume)
.send()
.await
.context("Failed to send HTTP request")?;
let status = response.status();
// Handle 416 Range Not Satisfiable specifically
if status.as_u16() == 416 {
// This can happen if the file is already complete
if downloaded_size > 0 && downloaded_size == total_size {
info!("File appears to be complete, got 416 with matching file size");
let progress_callback = self.progress_callback.clone();
let filename = output_path.file_name()
.and_then(|f| f.to_str())
.unwrap_or("file");
let callback = progress_callback.lock().await;
callback(1.0, filename, 0.0, Duration::from_secs(0));
return Ok(());
}
// If sizes don't match, start fresh
info!("Got 416 error but sizes don't match, starting fresh download");
downloaded_size = 0;
// Make a new request without range header
let response = self.client.get(url)
.header(USER_AGENT, "Sinfar NWN Installer")
.send()
.await
.context("Failed to send HTTP request")?;
if !response.status().is_success() {
return Err(anyhow!("Server returned error status: {}", response.status()));
}
} else if !status.is_success() {
return Err(anyhow!("Server returned error status: {}", status));
}
info!("Total download size: {} bytes", content_length);
// Open file in appropriate mode
let mut file = if downloaded_size > 0 {
info!("Resuming download from byte {}", downloaded_size);
tokio::fs::OpenOptions::new()
.write(true)
.append(true)
.open(output_path)
.await?
} else {
info!("Starting fresh download");
tokio::fs::File::create(output_path).await?
};
// Stream the response
let mut stream = response.bytes_stream();
let progress_callback = self.progress_callback.clone();
let filename = output_path.file_name()
.and_then(|f| f.to_str())
.unwrap_or("file");
let mut last_update = std::time::Instant::now();
let mut last_downloaded_size = downloaded_size;
let start_time = std::time::Instant::now();
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result?;
file.write_all(&chunk).await?;
downloaded_size += chunk.len() as u64;
// Only update progress every 100ms to avoid overwhelming the UI
let now = std::time::Instant::now();
if now.duration_since(last_update) > Duration::from_millis(100) || content_length == downloaded_size {
if content_length > 0 {
let progress = downloaded_size as f32 / content_length as f32;
// Calculate speed in bytes per second
let elapsed = now.duration_since(last_update).as_secs_f64();
let bytes_since_last = downloaded_size - last_downloaded_size;
let speed = bytes_since_last as f64 / elapsed;
// Calculate estimated time remaining
let remaining_bytes = content_length - downloaded_size;
let estimated_remaining = if speed > 0.0 {
Duration::from_secs_f64(remaining_bytes as f64 / speed)
} else {
Duration::from_secs(0)
};
let callback = progress_callback.lock().await;
callback(progress, filename, speed, estimated_remaining);
}
last_update = now;
last_downloaded_size = downloaded_size;
}
}
info!("Download completed: {}", output_path.display());
Ok(())
}
/// Downloads all required files for the installation
pub async fn download_all_files(&self, game_type: &GameType) -> Result<Vec<PathBuf>> {
let mut downloaded_files = Vec::new();
// Always download the content files
let content_zip_path = self.temp_dir.join("sinfar_all_files_v30.7z");
// Always try to download/resume the content files - don't skip based on existing size
self.download_file(
"https://sinfar.net/haks/sinfar_all_files_v30.7z",
&content_zip_path
).await?;
downloaded_files.push(content_zip_path);
// Download version-specific files
if *game_type == GameType::Diamond {
let launcher_path = self.temp_dir.join("sinfarx.exe");
// Always attempt download/resume
self.download_file(
"https://nwn.sinfar.net/files/sinfarx.exe",
&launcher_path
).await?;
downloaded_files.push(launcher_path);
} else if *game_type == GameType::EnhancedEdition {
// For EE, download the appropriate launcher for the platform
#[cfg(windows)]
{
let launcher_zip_path = self.temp_dir.join("sinfarLauncher.zip");
// Always attempt download/resume
self.download_file(
"https://nwn.sinfar.net/files/sinfarx/8181/win32_8181.zip",
&launcher_zip_path
).await?;
downloaded_files.push(launcher_zip_path);
}
#[cfg(unix)]
{
let launcher_zip_path = self.temp_dir.join("sinfarLauncher.zip");
// Always attempt download/resume
self.download_file(
"https://nwn.sinfar.net/files/sinfarx/8181/linux_8181.zip",
&launcher_zip_path
).await?;
downloaded_files.push(launcher_zip_path);
}
}
Ok(downloaded_files)
}
/// Check if a file exists and meets a minimum size requirement
fn file_exists_with_size(&self, path: &Path, min_size: u64) -> bool {
if let Ok(meta) = metadata(path) {
meta.is_file() && meta.len() >= min_size
} else {
false
}
}
/// Get the path to the temporary directory
pub fn get_temp_dir(&self) -> &Path {
&self.temp_dir
}
}

View File

@ -1,407 +0,0 @@
// src/installer/extractor.rs
use anyhow::{Result, Context};
use sevenz_rust::decompress;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::Mutex;
use log::{info, error};
use std::fs;
use std::io::{Read, Seek};
use crate::app::GameType;
pub struct ExtractionManager {
progress_callback: Arc<Mutex<Box<dyn Fn(f32, &str, std::time::Duration) + Send + Sync>>>,
last_update: Arc<Mutex<std::time::Instant>>,
last_processed: Arc<Mutex<u64>>,
}
impl ExtractionManager {
pub fn new<F>(progress_callback: F) -> Self
where
F: Fn(f32, &str, std::time::Duration) + Send + Sync + 'static
{
Self {
progress_callback: Arc::new(Mutex::new(Box::new(progress_callback))),
last_update: Arc::new(Mutex::new(std::time::Instant::now())),
last_processed: Arc::new(Mutex::new(0)),
}
}
// Helper function to calculate estimated time remaining
async fn update_progress(&self, processed: u64, total: u64, filename: &str) {
let progress = processed as f32 / total as f32;
let now = std::time::Instant::now();
let mut last_update = self.last_update.lock().await;
let mut last_processed = self.last_processed.lock().await;
let elapsed = now.duration_since(*last_update).as_secs_f64();
let bytes_since_last = processed - *last_processed;
let speed = bytes_since_last as f64 / elapsed;
let remaining_bytes = total - processed;
let estimated_remaining = if speed > 0.0 {
std::time::Duration::from_secs_f64(remaining_bytes as f64 / speed)
} else {
std::time::Duration::from_secs(0)
};
*last_update = now;
*last_processed = processed;
let callback = self.progress_callback.lock().await;
callback(progress, filename, estimated_remaining);
}
/// Extract a 7z archive with progress reporting
pub async fn extract_7z(&self, archive_path: &Path, output_dir: &Path) -> Result<()> {
info!("Starting extraction of 7z archive");
info!("Archive path: {}", archive_path.display());
info!("Output directory: {}", output_dir.display());
// Check if archive exists
if !archive_path.exists() {
error!("Archive file does not exist: {}", archive_path.display());
return Err(anyhow::anyhow!("Archive file does not exist"));
}
// Validate 7z signature and get archive size
let mut file = fs::File::open(&archive_path)?;
let mut signature = [0u8; 6];
file.read_exact(&mut signature)?;
// 7z files start with bytes [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C]
if signature != [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C] {
error!("Invalid 7z file signature: {:?}", signature);
return Err(anyhow::anyhow!("Invalid 7z file - got wrong signature. The downloaded file might be corrupted or might be an error page"));
}
info!("7z signature validation passed");
// Make sure the output directory exists
if !output_dir.exists() {
info!("Creating output directory: {}", output_dir.display());
tokio::fs::create_dir_all(output_dir).await?;
}
// Get file size for progress calculation
let archive_size = fs::metadata(archive_path)?.len();
info!("Archive size: {} bytes", archive_size);
// We'll handle extraction in a blocking task since sevenz-rust is synchronous
let archive_path = archive_path.to_path_buf();
let output_dir = output_dir.to_path_buf();
let progress_callback = self.progress_callback.clone();
let last_update = self.last_update.clone();
let last_processed = self.last_processed.clone();
info!("Starting blocking extraction task");
tokio::task::spawn_blocking(move || -> Result<()> {
// Open the archive for processing
let archive_file = fs::File::open(&archive_path)?;
// Create a wrapper around the file that tracks read progress
struct ProgressReader<R: Read + Seek> {
inner: R,
bytes_read: u64,
total_size: u64,
last_progress: f32,
progress_callback: Arc<Mutex<Box<dyn Fn(f32, &str, std::time::Duration) + Send + Sync>>>,
last_update: Arc<Mutex<std::time::Instant>>,
last_processed: Arc<Mutex<u64>>,
}
impl<R: Read + Seek> Read for ProgressReader<R> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let bytes = self.inner.read(buf)?;
self.bytes_read += bytes as u64;
// Calculate progress as a percentage
let progress = self.bytes_read as f32 / self.total_size as f32;
// Only update progress if it has changed by at least 1%
if progress - self.last_progress >= 0.01 {
self.last_progress = progress;
let progress_callback_clone = self.progress_callback.clone();
let last_update_clone = self.last_update.clone();
let last_processed_clone = self.last_processed.clone();
tokio::runtime::Handle::current().block_on(async move {
let callback = progress_callback_clone.lock().await;
let now = std::time::Instant::now();
let mut last_update = last_update_clone.lock().await;
let mut last_processed = last_processed_clone.lock().await;
let elapsed = now.duration_since(*last_update).as_secs_f64();
let bytes_since_last = self.bytes_read - *last_processed;
let speed = bytes_since_last as f64 / elapsed;
let remaining_bytes = self.total_size - self.bytes_read;
let estimated_remaining = if speed > 0.0 {
std::time::Duration::from_secs_f64(remaining_bytes as f64 / speed)
} else {
std::time::Duration::from_secs(0)
};
*last_update = now;
*last_processed = self.bytes_read;
callback(progress, "Extracting...", estimated_remaining);
});
}
Ok(bytes)
}
}
impl<R: Read + Seek> Seek for ProgressReader<R> {
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
self.inner.seek(pos)
}
}
let progress_reader = ProgressReader {
inner: archive_file,
bytes_read: 0,
total_size: archive_size,
last_progress: 0.0,
progress_callback: progress_callback.clone(),
last_update: last_update.clone(),
last_processed: last_processed.clone(),
};
info!("Starting decompression to: {}", output_dir.display());
// Decompress using our progress-tracking reader
match decompress(progress_reader, &output_dir) {
Ok(_) => info!("Decompression completed successfully"),
Err(e) => {
error!("Decompression failed: {}", e);
return Err(anyhow::anyhow!("Decompression failed: {}", e));
}
}
// Call progress update with 100% after decompression
let progress_callback_clone = progress_callback.clone();
tokio::runtime::Handle::current().block_on(async move {
let callback = progress_callback_clone.lock().await;
callback(1.0, "Complete", std::time::Duration::from_secs(0));
});
info!("Extraction task completed successfully");
Ok(())
}).await??;
info!("7z extraction process completed");
Ok(())
}
/// Extract a ZIP archive with progress reporting
pub async fn extract_zip(&self, archive_path: &Path, output_dir: &Path) -> Result<()> {
info!("Extracting ZIP archive: {} -> {}", archive_path.display(), output_dir.display());
// Make sure the output directory exists
if !output_dir.exists() {
tokio::fs::create_dir_all(output_dir).await?;
}
// We'll handle extraction in a blocking task since the zip crate is synchronous
let archive_path = archive_path.to_path_buf();
let output_dir = output_dir.to_path_buf();
let progress_callback = self.progress_callback.clone();
let last_update = self.last_update.clone();
let last_processed = self.last_processed.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let file = fs::File::open(&archive_path)?;
let mut archive = zip::ZipArchive::new(file)?;
// Calculate total uncompressed size
let total_size: u64 = {
let mut size = 0;
for i in 0..archive.len() {
if let Ok(file) = archive.by_index(i) {
size += file.size();
}
}
size
};
info!("ZIP archive total uncompressed size: {} bytes", total_size);
let mut processed_bytes: u64 = 0;
// Process files in batches for better performance
let mut pending_dirs = Vec::new();
// First pass: create all directories (this prevents race conditions)
for i in 0..archive.len() {
let file = archive.by_index(i)?;
let outpath = match file.enclosed_name() {
Some(path) => output_dir.join(path),
None => continue,
};
if file.name().ends_with('/') {
pending_dirs.push(outpath);
} else if let Some(p) = outpath.parent() {
pending_dirs.push(p.to_path_buf());
}
}
// Create all directories at once
for dir in pending_dirs.iter().collect::<std::collections::HashSet<_>>() {
fs::create_dir_all(dir)?;
}
// Second pass: extract all files
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
let outpath = match file.enclosed_name() {
Some(path) => output_dir.join(path),
None => continue,
};
if !file.name().ends_with('/') {
let mut outfile = fs::File::create(&outpath)?;
let file_size = file.size();
std::io::copy(&mut file, &mut outfile)?;
processed_bytes += file_size;
// Update progress based on processed bytes
let progress = processed_bytes as f32 / total_size as f32;
let filename = outpath.file_name()
.and_then(|f| f.to_str())
.unwrap_or("unknown");
// Update progress less frequently to reduce overhead
if i % 10 == 0 || progress >= 1.0 {
let progress_callback_clone = progress_callback.clone();
let last_update_clone = last_update.clone();
let last_processed_clone = last_processed.clone();
tokio::runtime::Handle::current().block_on(async move {
let callback = progress_callback_clone.lock().await;
let now = std::time::Instant::now();
let mut last_update = last_update_clone.lock().await;
let mut last_processed = last_processed_clone.lock().await;
let elapsed = now.duration_since(*last_update).as_secs_f64();
let bytes_since_last = processed_bytes - *last_processed;
let speed = bytes_since_last as f64 / elapsed;
let remaining_bytes = total_size - processed_bytes;
let estimated_remaining = if speed > 0.0 {
std::time::Duration::from_secs_f64(remaining_bytes as f64 / speed)
} else {
std::time::Duration::from_secs(0)
};
*last_update = now;
*last_processed = processed_bytes;
callback(progress, filename, estimated_remaining);
});
}
}
// Fix file permissions on Unix
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Some(mode) = file.unix_mode() {
fs::set_permissions(&outpath, fs::Permissions::from_mode(mode))?;
}
}
}
// Ensure we show 100% at the end
let progress_callback_clone = progress_callback.clone();
tokio::runtime::Handle::current().block_on(async move {
let callback = progress_callback_clone.lock().await;
callback(1.0, "Complete", std::time::Duration::from_secs(0));
});
info!("ZIP extraction completed successfully");
Ok(())
}).await??;
Ok(())
}
/// Handles extraction of all downloaded files to their appropriate locations
pub async fn install_all_files(
&self,
downloaded_files: &[PathBuf],
install_path: &Path,
game_type: &GameType,
ee_exe_path: Option<&Path>
) -> Result<()> {
info!("Starting installation of all files");
info!("Install path: {}", install_path.display());
info!("Game type: {:?}", game_type);
if let Some(ee_path) = ee_exe_path {
info!("EE executable path: {}", ee_path.display());
}
info!("Available downloaded files:");
for file in downloaded_files {
info!(" {}", file.display());
}
// Find and extract the main content files
let content_archive = downloaded_files.iter()
.find(|p| p.file_name().map_or(false, |f| f.to_str().unwrap_or("").contains("sinfar_all_files")))
.context("Content archive not found in downloaded files")?;
info!("Found content archive: {}", content_archive.display());
// Extract main content
info!("Starting extraction of main content");
self.extract_7z(content_archive, install_path).await?;
// Handle game-specific files
info!("Processing game-specific files");
match game_type {
GameType::Diamond => {
info!("Installing Diamond edition launcher");
// For Diamond, find and copy the launcher
let launcher = downloaded_files.iter()
.find(|p| p.file_name().map_or(false, |f| f.to_str().unwrap_or("").contains("sinfarx.exe")))
.context("Launcher executable not found in downloaded files")?;
// Copy launcher to install directory
let target_path = install_path.join("sinfarx.exe");
info!("Copying launcher from {} to {}", launcher.display(), target_path.display());
tokio::fs::copy(launcher, &target_path).await?;
info!("Launcher copied successfully");
}
GameType::EnhancedEdition => {
info!("Installing Enhanced Edition launcher");
// For EE, find the launcher zip
let launcher_zip = downloaded_files.iter()
.find(|p| p.file_name().map_or(false, |f| f.to_str().unwrap_or("").contains("sinfarLauncher.zip")))
.context("Launcher ZIP not found in downloaded files")?;
// Extract to the EE executable directory
if let Some(ee_path) = ee_exe_path {
// Create the bin/win32_8181 directory if it doesn't exist (Windows)
// or bin/linux_8181 directory (Linux)
#[cfg(windows)]
let extract_dir = ee_path.join("bin");
#[cfg(unix)]
let extract_dir = ee_path.join("bin");
info!("Extracting EE launcher to {}", extract_dir.display());
// Extract the launcher
self.extract_zip(launcher_zip, &extract_dir).await?;
info!("EE launcher extracted successfully");
} else {
error!("EE executable path is required but not provided");
return Err(anyhow::anyhow!("EE executable path is required for EE installation"));
}
}
}
info!("All files installed successfully");
Ok(())
}
}

View File

@ -1,7 +0,0 @@
pub mod downloader;
pub mod extractor;
pub mod validator;
pub mod shortcut;
pub mod seven_z_dearchiver;
pub use seven_z_dearchiver::SevenZDearchiver;

View File

@ -1,112 +0,0 @@
use anyhow::Result;
use std::path::Path;
use std::sync::Arc;
use tokio::sync::{Mutex, watch};
use std::io::{Read, Seek};
use std::fs;
use std::time::{Duration, Instant};
pub struct SevenZDearchiver {
cancel_tx: watch::Sender<bool>,
cancel_rx: watch::Receiver<bool>,
}
impl SevenZDearchiver {
pub fn new() -> Self {
let (cancel_tx, cancel_rx) = watch::channel(false);
Self {
cancel_tx,
cancel_rx,
}
}
pub fn cancel(&self) {
let _ = self.cancel_tx.send(true);
}
pub async fn extract<F>(&self, archive_path: &Path, output_dir: &Path, progress_callback: F) -> Result<()>
where
F: Fn(f32, Duration) + Send + Sync + 'static
{
// Create wrapper for progress tracking
struct ProgressReader<R: Read + Seek> {
inner: R,
bytes_read: u64,
total_size: u64,
last_update: Instant,
cancel_signal: watch::Receiver<bool>,
progress_callback: Arc<Box<dyn Fn(f32, Duration) + Send + Sync>>,
}
impl<R: Read + Seek> Read for ProgressReader<R> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
// Check cancellation
if *self.cancel_signal.borrow() {
return Ok(0); // Return 0 to indicate EOF and stop extraction
}
let bytes = self.inner.read(buf)?;
self.bytes_read += bytes as u64;
let now = Instant::now();
let elapsed = now.duration_since(self.last_update);
// Update progress every 100ms
if elapsed.as_millis() > 100 {
let progress = self.bytes_read as f32 / self.total_size as f32;
// Calculate estimated time remaining
let speed = self.bytes_read as f64 / elapsed.as_secs_f64();
let remaining_bytes = self.total_size - self.bytes_read;
let eta = if speed > 0.0 {
Duration::from_secs_f64(remaining_bytes as f64 / speed)
} else {
Duration::from_secs(0)
};
(self.progress_callback)(progress, eta);
self.last_update = now;
}
Ok(bytes)
}
}
impl<R: Read + Seek> Seek for ProgressReader<R> {
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
self.inner.seek(pos)
}
}
// Start extraction in blocking task
let cancel_rx = self.cancel_rx.clone();
let progress_cb = Arc::new(Box::new(progress_callback) as Box<dyn Fn(f32, Duration) + Send + Sync>);
let archive_path = archive_path.to_owned();
let output_dir = output_dir.to_owned();
tokio::task::spawn_blocking(move || -> Result<()> {
let file = fs::File::open(&archive_path)?;
let file_size = file.metadata()?.len();
let progress_reader = ProgressReader {
inner: file,
bytes_read: 0,
total_size: file_size,
last_update: Instant::now(),
cancel_signal: cancel_rx,
progress_callback: progress_cb,
};
sevenz_rust::decompress(progress_reader, &output_dir)?;
Ok(())
}).await??;
Ok(())
}
}
impl Default for SevenZDearchiver {
fn default() -> Self {
Self::new()
}
}

View File

@ -1,62 +0,0 @@
use std::path::{Path, PathBuf};
use anyhow::Result;
use std::io::Write;
pub fn create_shortcut(
target_path: &Path,
shortcut_name: &str,
icon_path: Option<&Path>,
) -> Result<()> {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
use std::process::Command;
let desktop_path = dirs::desktop_dir().unwrap_or_else(|| PathBuf::from("."));
let shortcut_path = desktop_path.join(format!("{}.lnk", shortcut_name));
let powershell_command = format!(
"$s = (New-Object -ComObject WScript.Shell).CreateShortcut('{}'); $s.TargetPath = '{}'; {}; $s.Save()",
shortcut_path.to_string_lossy(),
target_path.to_string_lossy(),
if let Some(icon) = icon_path {
format!("$s.IconLocation = '{}'", icon.to_string_lossy())
} else {
String::new()
}
);
Command::new("powershell")
.args(["-Command", &powershell_command])
.creation_flags(0x08000000) // CREATE_NO_WINDOW
.output()?;
}
#[cfg(unix)]
{
let desktop_path = dirs::desktop_dir().unwrap_or_else(|| PathBuf::from("."));
let shortcut_path = desktop_path.join(format!("{}.desktop", shortcut_name));
let mut desktop_file = std::fs::File::create(shortcut_path)?;
writeln!(desktop_file, "[Desktop Entry]")?;
writeln!(desktop_file, "Type=Application")?;
writeln!(desktop_file, "Name={}", shortcut_name)?;
writeln!(desktop_file, "Exec={}", target_path.to_string_lossy())?;
if let Some(icon) = icon_path {
writeln!(desktop_file, "Icon={}", icon.to_string_lossy())?;
}
writeln!(desktop_file, "Terminal=false")?;
// Make the .desktop file executable
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&shortcut_path)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&shortcut_path, perms)?;
}
}
Ok(())
}

View File

@ -1,19 +0,0 @@
pub mod validator {
use std::path::Path;
pub fn validate_diamond_path(path: &Path) -> bool {
path.join("tlk").exists() && path.join("hak").exists()
}
pub fn validate_ee_path(path: &Path) -> bool {
path.join("tlk").exists() && path.join("hak").exists()
}
pub fn validate_ee_exe_path(path: &Path) -> bool {
#[cfg(windows)]
return path.join("bin").join("win32").exists();
#[cfg(unix)]
return path.join("bin").join("linux-x86").exists() || path.join("bin").join("linux-x86_64").exists();
}
}

View File

@ -1,51 +0,0 @@
use eframe::{NativeOptions, run_native, App};
use log::info;
use tokio::runtime::Runtime;
use std::sync::Arc;
use eframe::IconData;
mod app;
mod ui;
mod installer;
//mod util;
fn main() {
env_logger::init();
info!("Starting Sinfar NWN Custom Content Installer");
let rt = Runtime::new().expect("Failed to create Tokio runtime");
let rt = Arc::new(rt);
let options = NativeOptions {
initial_window_size: Some(eframe::egui::vec2(600.0, 400.0)),
resizable: false,
icon_data: load_icon(),
..Default::default()
};
let app_rt = rt.clone();
run_native(
"Sinfar NWN Custom Content Installer",
options,
Box::new(move |cc| Box::new(app::SinfarInstallerApp::new(cc, app_rt.clone()))),
).expect("Failed to start application");
}
fn load_icon() -> Option<eframe::IconData> {
#[cfg(feature = "embed_icon")]
{
let icon_bytes = include_bytes!("../assets/installerico.ico");
// Convert ico to rgba data
match image::load_from_memory(icon_bytes) {
Ok(image) => {
let image_rgba = image.to_rgba8();
let (width, height) = image_rgba.dimensions();
let rgba = image_rgba.into_raw();
Some(eframe::IconData { rgba, width, height })
},
Err(_) => None,
}
}
#[cfg(not(feature = "embed_icon"))]
None
}

View File

@ -1,60 +0,0 @@
pub mod download_progress {
use eframe::egui::{Ui, ProgressBar, Color32};
use crate::app::SinfarInstallerApp;
use crate::ui::format_duration;
fn format_bytes(bytes: f64) -> String {
const KB: f64 = 1024.0;
const MB: f64 = KB * 1024.0;
if bytes >= MB {
format!("{:.1} MB/s", bytes / MB)
} else if bytes >= KB {
format!("{:.1} KB/s", bytes / KB)
} else {
format!("{:.0} B/s", bytes)
}
}
pub fn render(ui: &mut Ui, app: &mut SinfarInstallerApp) {
ui.heading("Downloading Sinfar Custom Content");
ui.add_space(10.0);
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.label("Downloading:");
ui.add(ProgressBar::new(app.download_progress).show_percentage());
});
if let Ok(state) = app.download_state.lock() {
if state.completed {
ui.colored_label(Color32::GREEN, "Download Complete");
} else if app.download_progress > 0.0 && app.download_progress < 1.0 {
ui.horizontal(|ui| {
ui.label(format_bytes(app.download_speed));
ui.label("");
ui.label(format_duration(app.estimated_remaining));
});
}
}
});
ui.add_space(10.0);
// Add cancel button only during active download
if app.download_progress > 0.0 && app.download_progress < 1.0 {
if ui.button("Cancel Download").clicked() {
app.cancel_download();
}
}
if let Some(error) = &app.download_error {
ui.colored_label(Color32::RED, format!("Error: {}", error));
if ui.button("Retry").clicked() {
app.download_error = None;
app.download_progress = 0.0;
app.start_download();
}
}
}
}

View File

@ -1,38 +0,0 @@
pub mod game_selection {
use eframe::egui::Ui;
use crate::app::{SinfarInstallerApp, GameType};
use std::sync::Once;
static INIT: Once = Once::new();
pub fn render(ui: &mut Ui, app: &mut SinfarInstallerApp) {
INIT.call_once(|| {
print_selected_game_type(&app.game_type);
});
ui.heading("Select your game's version:");
ui.add_space(10.0);
ui.vertical(|ui| {
if ui.radio_value(&mut app.game_type, Some(GameType::Diamond), "Neverwinter Nights Diamond Edition").clicked() {
// Handle selection
print_selected_game_type(&app.game_type);
}
if ui.radio_value(&mut app.game_type, Some(GameType::EnhancedEdition), "Neverwinter Nights: Enhanced Edition").clicked() {
// Handle selection
print_selected_game_type(&app.game_type);
}
});
}
fn print_selected_game_type(game_type: &Option<GameType>){
match game_type {
Some(game_type_ref) => match game_type_ref {
GameType::Diamond => println!("Diamond Edition selected"),
GameType::EnhancedEdition => println!("Enhanced Edition selected"),
},
None => println!("No game selected. (If you see this.. you fucked up.)"),
}
}
}

View File

@ -1,35 +0,0 @@
pub mod installation_progress {
use eframe::egui::{Ui, ProgressBar, Color32};
use crate::app::SinfarInstallerApp;
use crate::ui::format_duration;
pub fn render(ui: &mut Ui, app: &mut SinfarInstallerApp) {
ui.heading("Installing Sinfar Custom Content");
ui.add_space(10.0);
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.label("Extracting files:");
ui.add(ProgressBar::new(app.extraction_progress).show_percentage());
});
if let Ok(state) = app.download_state.lock() {
if state.completed {
ui.colored_label(Color32::GREEN, "Installation Complete");
} else if app.extraction_progress > 0.0 && app.extraction_progress < 1.0 {
ui.horizontal(|ui| {
ui.label(format_duration(state.estimated_remaining));
});
}
}
});
if let Some(error) = &app.install_error {
ui.colored_label(Color32::RED, error);
if ui.button("Retry").clicked() {
app.install_error = None;
app.start_installation();
}
}
}
}

View File

@ -1,15 +0,0 @@
pub mod download_progress;
pub mod game_selection;
pub mod installation_progress;
pub mod path_selection;
use std::time::Duration;
pub fn format_duration(duration: Duration) -> String {
let secs = duration.as_secs();
if secs >= 3600 {
format!("{:02}h{:02}m{:02}s estimated remaining time", secs / 3600, (secs % 3600) / 60, secs % 60)
} else {
format!("{:02}m{:02}s estimated remaining time", secs / 60, secs % 60)
}
}

View File

@ -1,105 +0,0 @@
pub mod path_selection {
use eframe::egui::{Ui, TextEdit};
use crate::app::{SinfarInstallerApp, GameType};
use crate::installer::validator::*;
use rfd::FileDialog;
use std::path::PathBuf;
pub fn render(ui: &mut Ui, app: &mut SinfarInstallerApp) {
ui.heading("Select installation paths");
ui.add_space(10.0);
// Set default paths if not set
if app.install_path.is_none() {
app.install_path = Some(default_install_path(&app.game_type.clone().unwrap_or(GameType::Diamond)));
}
if app.game_type == Some(GameType::EnhancedEdition) && app.ee_exe_path.is_none() {
app.ee_exe_path = Some(default_ee_exe_path());
}
ui.label("Please select your Neverwinter Nights install folder:");
ui.horizontal(|ui| {
let mut path_str = app.install_path.clone().unwrap_or_default().to_string_lossy().to_string();
let response = ui.add(TextEdit::singleline(&mut path_str).desired_width(ui.available_width() - 80.0));
if response.changed() {
app.install_path = Some(PathBuf::from(path_str));
}
if ui.button("Browse...").clicked() {
if let Some(path) = FileDialog::new()
.set_title("Select Neverwinter Nights Folder")
.set_directory(app.install_path.clone().unwrap_or_default())
.pick_folder() {
app.install_path = Some(path);
}
}
});
let is_content_path_valid = match &app.game_type {
Some(GameType::Diamond) => validator::validate_diamond_path(app.install_path.as_ref().unwrap()),
Some(GameType::EnhancedEdition) => validator::validate_ee_path(app.install_path.as_ref().unwrap()),
None => false,
};
if !is_content_path_valid {
ui.colored_label(eframe::egui::Color32::RED, "Invalid path: The selected folder must contain both 'tlk' and 'hak' subfolders.");
}
// Only show EE path selection if EE is selected
if app.game_type == Some(GameType::EnhancedEdition) {
ui.add_space(10.0);
ui.label("Select the location of the nwn executable for Enhanced Edition:");
ui.horizontal(|ui| {
let mut ee_path_str = app.ee_exe_path.clone().unwrap_or_default().to_string_lossy().to_string();
let response = ui.add(TextEdit::singleline(&mut ee_path_str).desired_width(ui.available_width() - 80.0));
if response.changed() {
app.ee_exe_path = Some(PathBuf::from(ee_path_str));
}
if ui.button("Browse...").clicked() {
if let Some(path) = FileDialog::new()
.set_title("Select Neverwinter Nights executable location")
.set_directory(app.ee_exe_path.clone().unwrap_or_default())
.pick_folder() {
app.ee_exe_path = Some(path);
}
}
});
if !validator::validate_ee_exe_path(app.ee_exe_path.as_ref().unwrap()) {
ui.colored_label(eframe::egui::Color32::RED, "Invalid path: Neverwinter Night EE not detected.");
}
}
}
fn default_install_path(game_type: &GameType) -> PathBuf {
match game_type {
GameType::Diamond => {
#[cfg(windows)]
return PathBuf::from("C:\\Program Files (x86)\\GOG Galaxy\\Games\\NWN Diamond");
#[cfg(unix)]
return PathBuf::from("/home").join(whoami::username()).join(".local/share/NWN Diamond");
},
GameType::EnhancedEdition => {
#[cfg(windows)]
return PathBuf::from(dirs::document_dir().unwrap_or_default()).join("Neverwinter Nights");
#[cfg(unix)]
return PathBuf::from("/home").join(whoami::username()).join(".local/share/Neverwinter Nights");
},
}
}
fn default_ee_exe_path() -> PathBuf {
#[cfg(windows)]
return PathBuf::from("C:\\Program Files (x86)\\GOG Galaxy\\Games\\Neverwinter Nights Enhanced Edition");
#[cfg(unix)]
return PathBuf::from("/home").join(whoami::username()).join(".local/share/Steam/steamapps/common/Neverwinter Nights");
}
}

View File

@ -1 +0,0 @@
pub mod platform;

View File

@ -1,280 +0,0 @@
// src/util/platform.rs
use std::path::{Path, PathBuf};
use anyhow::Result;
/// Platform-agnostic function to get the user's documents directory
pub fn get_documents_dir() -> Option<PathBuf> {
dirs::document_dir()
}
/// Get the default installation directory for NWN based on platform
pub fn get_default_nwn_dir(is_ee: bool) -> PathBuf {
if is_ee {
#[cfg(windows)]
{
if let Some(docs) = get_documents_dir() {
return docs.join("Neverwinter Nights");
} else {
return PathBuf::from("C:\\Users\\Public\\Documents\\Neverwinter Nights");
}
}
#[cfg(unix)]
{
if let Some(home) = dirs::home_dir() {
return home.join(".local/share/Neverwinter Nights");
} else {
return PathBuf::from("/home").join(whoami::username()).join(".local/share/Neverwinter Nights");
}
}
} else {
// Diamond Edition
#[cfg(windows)]
{
// Try common installation paths for GOG and Steam
let gog_path = PathBuf::from("C:\\Program Files (x86)\\GOG Galaxy\\Games\\NWN Diamond");
if gog_path.exists() {
return gog_path;
}
let steam_path = PathBuf::from("C:\\Program Files (x86)\\Steam\\steamapps\\common\\NWN Diamond");
if steam_path.exists() {
return steam_path;
}
// Default to GOG path even if it doesn't exist
return gog_path;
}
#[cfg(unix)]
{
if let Some(home) = dirs::home_dir() {
// Check common Linux installation locations
let steam_path = home.join(".steam/steam/steamapps/common/NWN Diamond");
if steam_path.exists() {
return steam_path;
}
let gog_path = home.join("GOG Games/NWN Diamond");
if gog_path.exists() {
return gog_path;
}
return home.join(".local/share/NWN Diamond");
} else {
return PathBuf::from("/home").join(whoami::username()).join(".local/share/NWN Diamond");
}
}
}
}
/// Get the executable name with the correct extension based on platform
pub fn get_executable_name(base_name: &str) -> String {
#[cfg(windows)]
return format!("{}.exe", base_name);
#[cfg(unix)]
return base_name.to_string();
}
/// Check if we need elevated permissions for a path
pub fn needs_elevation(path: &Path) -> bool {
#[cfg(windows)]
{
// Common paths that typically need elevation on Windows
path.to_string_lossy().to_lowercase().contains("program files") ||
path.to_string_lossy().to_lowercase().contains("programfiles") ||
path.to_string_lossy().to_lowercase().contains("\\windows\\")
}
#[cfg(unix)]
{
// Common paths that typically need elevation on Linux
path.to_string_lossy().starts_with("/usr") ||
path.to_string_lossy().starts_with("/opt") ||
path.to_string_lossy().starts_with("/bin") ||
path.to_string_lossy().starts_with("/sbin")
}
}
/// Request elevated permissions
pub fn request_elevation() -> Result<bool> {
#[cfg(windows)]
{
use std::process::Command;
use std::env;
let executable = env::current_exe()?;
// Don't try to re-elevate if we're already elevated
if is_elevated()? {
return Ok(true);
}
// Use the Windows ShellExecute to prompt for elevation
let result = Command::new("powershell")
.args(&[
"-Command",
&format!("Start-Process -FilePath '{}' -Verb RunAs", executable.to_string_lossy()),
])
.output()?;
// If the command executed successfully, exit the current non-elevated process
if result.status.success() {
std::process::exit(0);
}
Ok(false)
}
#[cfg(unix)]
{
use std::process::Command;
use std::env;
let executable = env::current_exe()?;
// Don't try to re-elevate if we're already elevated
if is_elevated()? {
return Ok(true);
}
// Try pkexec first (GNOME/KDE)
if Command::new("which").arg("pkexec").output()?.status.success() {
let _ = Command::new("pkexec")
.arg(executable)
.spawn()?;
std::process::exit(0);
}
// Try gksudo next (older GNOME)
if Command::new("which").arg("gksudo").output()?.status.success() {
let _ = Command::new("gksudo")
.arg("--")
.arg(executable)
.spawn()?;
std::process::exit(0);
}
// Try kdesudo (KDE)
if Command::new("which").arg("kdesudo").output()?.status.success() {
let _ = Command::new("kdesudo")
.arg("--")
.arg(executable)
.spawn()?;
std::process::exit(0);
}
// Fallback to terminal-based sudo
if Command::new("which").arg("sudo").output()?.status.success() {
let _ = Command::new("sudo")
.arg(executable)
.spawn()?;
std::process::exit(0);
}
Ok(false)
}
}
/// Check if the current process has elevated permissions
pub fn is_elevated() -> Result<bool> {
#[cfg(windows)]
{
use std::process::Command;
// This command succeeds only if the process has admin privileges
let output = Command::new("net")
.args(&["session"])
.output()?;
Ok(output.status.success())
}
#[cfg(unix)]
{
// On Unix-like systems, check if we're root (uid 0)
Ok(unsafe { libc::geteuid() == 0 })
}
}
/// Create a desktop shortcut in a platform-specific way
pub fn create_desktop_shortcut(
target: &Path,
name: &str,
icon: Option<&Path>,
description: Option<&str>
) -> Result<()> {
#[cfg(windows)]
{
use std::process::Command;
let desktop = if let Some(desktop_dir) = dirs::desktop_dir() {
desktop_dir
} else {
return Err(anyhow::anyhow!("Could not find desktop directory"));
};
let shortcut_path = desktop.join(format!("{}.lnk", name));
// Create Windows shortcut using PowerShell
let mut ps_cmd = String::from("$ws = New-Object -ComObject WScript.Shell; ");
ps_cmd.push_str(&format!("$s = $ws.CreateShortcut('{}'); ", shortcut_path.to_string_lossy()));
ps_cmd.push_str(&format!("$s.TargetPath = '{}'; ", target.to_string_lossy()));
if let Some(icon_path) = icon {
ps_cmd.push_str(&format!("$s.IconLocation = '{}'; ", icon_path.to_string_lossy()));
}
if let Some(desc) = description {
ps_cmd.push_str(&format!("$s.Description = '{}'; ", desc));
}
ps_cmd.push_str("$s.Save()");
Command::new("powershell")
.args(&["-Command", &ps_cmd])
.output()?;
Ok(())
}
#[cfg(unix)]
{
use std::fs::File;
use std::io::Write;
let desktop = if let Some(desktop_dir) = dirs::desktop_dir() {
desktop_dir
} else {
return Err(anyhow::anyhow!("Could not find desktop directory"));
};
let shortcut_path = desktop.join(format!("{}.desktop", name));
let mut file = File::create(&shortcut_path)?;
writeln!(file, "[Desktop Entry]")?;
writeln!(file, "Type=Application")?;
writeln!(file, "Name={}", name)?;
writeln!(file, "Exec={}", target.to_string_lossy())?;
if let Some(icon_path) = icon {
writeln!(file, "Icon={}", icon_path.to_string_lossy())?;
}
if let Some(desc) = description {
writeln!(file, "Comment={}", desc)?;
}
writeln!(file, "Terminal=false")?;
// Make executable
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&shortcut_path)?.permissions();
perms.set_mode(0o755); // rwxr-xr-x
std::fs::set_permissions(&shortcut_path, perms)?;
Ok(())
}
}