Rust WIP
This commit is contained in:
parent
ea9b68b235
commit
6f90e4bf68
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
SinfarInstaller-v30.exe
|
||||
win32_8181.7z
|
||||
sinfar_all_files_v30.7z
|
||||
rust/target
|
||||
|
4795
rust/Cargo.lock
generated
Normal file
4795
rust/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
rust/Cargo.toml
Normal file
31
rust/Cargo.toml
Normal file
@ -0,0 +1,31 @@
|
||||
[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
|
BIN
rust/assets/installerico.ico
Normal file
BIN
rust/assets/installerico.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 220 KiB |
246
rust/src/app.rs
Normal file
246
rust/src/app.rs
Normal file
@ -0,0 +1,246 @@
|
||||
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 completed: bool,
|
||||
pub files: Option<Vec<PathBuf>>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
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 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,
|
||||
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() {
|
||||
self.download_progress = state.progress;
|
||||
|
||||
// if state.completed {
|
||||
// if let Some(files) = &state.files {
|
||||
// // Store the files for installation
|
||||
// //self.next_state(); // Proceed to installation
|
||||
// //self.start_installation();
|
||||
// }
|
||||
// }
|
||||
|
||||
if let Some(error) = &state.error {
|
||||
self.download_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,
|
||||
}
|
||||
},
|
||||
_ => 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_installation();
|
||||
}
|
||||
}
|
||||
|
||||
if self.state != InstallerState::GameSelection {
|
||||
if ui.button("Back").clicked() {
|
||||
self.previous_state();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Implementation of key installer functions
|
||||
impl SinfarInstallerApp<> {
|
||||
pub fn start_download(&mut self) {
|
||||
|
||||
// let temp_dir = std::env::temp_dir().join("sinfar_installer");
|
||||
// println!("{}", temp_dir.display())
|
||||
|
||||
// Reset progress and errors
|
||||
self.download_progress = 0.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| {
|
||||
// Update progress through shared state
|
||||
if let Ok(mut state) = progress_state.lock() {
|
||||
state.progress = progress;
|
||||
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 start_installation(&mut self) {
|
||||
|
||||
|
||||
// This would be spawned in a separate thread
|
||||
//let game_type = self.game_type.clone().unwrap();
|
||||
//let install_path = self.install_path.clone().unwrap();
|
||||
//let ee_exe_path = self.ee_exe_path.clone();
|
||||
|
||||
// Implementation with sevenz-rust would go here
|
||||
// Update self.extraction_progress as files are extracted
|
||||
}
|
||||
}
|
242
rust/src/installer/downloader.rs
Normal file
242
rust/src/installer/downloader.rs
Normal file
@ -0,0 +1,242 @@
|
||||
// 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, error};
|
||||
use tokio::fs::File;
|
||||
use crate::app::{GameType};
|
||||
|
||||
pub struct DownloadManager {
|
||||
client: Client,
|
||||
progress_callback: Arc<Mutex<Box<dyn Fn(f32, &str) + Send + Sync>>>,
|
||||
temp_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl DownloadManager {
|
||||
pub fn new<F>(progress_callback: F) -> Self
|
||||
where
|
||||
F: Fn(f32, &str) + 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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?;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we can resume a previous download
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(USER_AGENT, HeaderValue::from_static("Sinfar NWN Installer"));
|
||||
|
||||
let mut downloaded_size: u64 = 0;
|
||||
|
||||
if output_path.exists() {
|
||||
if let Ok(metadata) = tokio::fs::metadata(output_path).await {
|
||||
downloaded_size = metadata.len();
|
||||
if downloaded_size > 0 {
|
||||
info!("Resuming download from byte {}", downloaded_size);
|
||||
headers.insert(RANGE, HeaderValue::from_str(&format!("bytes={}-", downloaded_size))?);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let response = self.client.get(url)
|
||||
.headers(headers)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to send HTTP request")?;
|
||||
|
||||
// Check if the server supports resuming
|
||||
let can_resume = response.status().is_success() && response.status().as_u16() == 206;
|
||||
let status = response.status();
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(anyhow!("Server returned error status: {}", status));
|
||||
}
|
||||
|
||||
// Get total file size
|
||||
let content_length = if can_resume {
|
||||
if let Some(content_range) = response.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 {
|
||||
response.content_length().unwrap_or(0) + downloaded_size
|
||||
}
|
||||
} else {
|
||||
response.content_length().unwrap_or(0) + downloaded_size
|
||||
}
|
||||
} else {
|
||||
response.content_length().unwrap_or(0)
|
||||
};
|
||||
|
||||
info!("Download size: {} bytes", content_length);
|
||||
|
||||
// Open file in appropriate mode (append if resuming, create/truncate if starting fresh)
|
||||
let mut file = if can_resume && downloaded_size > 0 {
|
||||
info!("Opening file for append");
|
||||
tokio::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.append(true)
|
||||
.open(output_path)
|
||||
.await?
|
||||
} else {
|
||||
info!("Creating new file");
|
||||
downloaded_size = 0;
|
||||
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();
|
||||
|
||||
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;
|
||||
let callback = progress_callback.lock().await;
|
||||
callback(progress, filename);
|
||||
}
|
||||
last_update = now;
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
// Check if file already exists and is of expected size
|
||||
if !self.file_exists_with_size(&content_zip_path, 100_000_000) {
|
||||
// File doesn't exist or is too small, download it
|
||||
self.download_file(
|
||||
"https://sinfar.net/haks/sinfar_all_files_v30.7z",
|
||||
&content_zip_path
|
||||
).await?;
|
||||
} else {
|
||||
// File exists, update progress to 100%
|
||||
let callback = self.progress_callback.lock().await;
|
||||
callback(1.0, "sinfar_all_files_v30.7z");
|
||||
}
|
||||
|
||||
downloaded_files.push(content_zip_path);
|
||||
|
||||
// Download version-specific files
|
||||
if *game_type == GameType::Diamond {
|
||||
let launcher_path = self.temp_dir.join("sinfarx.exe");
|
||||
|
||||
if !self.file_exists_with_size(&launcher_path, 10_000) {
|
||||
self.download_file(
|
||||
"https://nwn.sinfar.net/files/sinfarx.exe",
|
||||
&launcher_path
|
||||
).await?;
|
||||
} else {
|
||||
let callback = self.progress_callback.lock().await;
|
||||
callback(1.0, "sinfarx.exe");
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
if !self.file_exists_with_size(&launcher_zip_path, 10_000) {
|
||||
self.download_file(
|
||||
"https://nwn.sinfar.net/files/sinfarx/8181/win32_8181.zip",
|
||||
&launcher_zip_path
|
||||
).await?;
|
||||
} else {
|
||||
let callback = self.progress_callback.lock().await;
|
||||
callback(1.0, "sinfarLauncher.zip");
|
||||
}
|
||||
|
||||
downloaded_files.push(launcher_zip_path);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let launcher_zip_path = self.temp_dir.join("sinfarLauncher.zip");
|
||||
|
||||
if !self.file_exists_with_size(&launcher_zip_path, 10_000) {
|
||||
self.download_file(
|
||||
"https://nwn.sinfar.net/files/sinfarx/8181/linux_8181.zip",
|
||||
&launcher_zip_path
|
||||
).await?;
|
||||
} else {
|
||||
let callback = self.progress_callback.lock().await;
|
||||
callback(1.0, "sinfarLauncher.zip");
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
190
rust/src/installer/extractor.rs
Normal file
190
rust/src/installer/extractor.rs
Normal file
@ -0,0 +1,190 @@
|
||||
// src/installer/extractor.rs
|
||||
use anyhow::{Result, Context};
|
||||
use sevenz_rust::{Archive, BlockDecoder, decompress};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use log::{info, warn, error};
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub struct ExtractionManager {
|
||||
progress_callback: Arc<Mutex<Box<dyn Fn(f32, &str) + Send + Sync>>>,
|
||||
}
|
||||
|
||||
impl ExtractionManager {
|
||||
pub fn new<F>(progress_callback: F) -> Self
|
||||
where
|
||||
F: Fn(f32, &str) + Send + Sync + 'static
|
||||
{
|
||||
Self {
|
||||
progress_callback: Arc::new(Mutex::new(Box::new(progress_callback))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a 7z archive with progress reporting
|
||||
pub async fn extract_7z(&self, archive_path: &Path, output_dir: &Path) -> Result<()> {
|
||||
info!("Extracting 7z 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?;
|
||||
}
|
||||
|
||||
// Get file size for progress calculation
|
||||
let file_size = fs::metadata(archive_path)?.len();
|
||||
|
||||
// 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();
|
||||
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
let archive_file = fs::File::open(&archive_path)?;
|
||||
let out = fs::create_dir_all(&output_dir)?;
|
||||
|
||||
// decompress takes a Read + Seek and a destination directory
|
||||
decompress(&archive_file, &output_dir)?;
|
||||
|
||||
// Call progress update with 100% after decompression
|
||||
let filename = archive_path.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or("archive");
|
||||
|
||||
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, filename); // 100% done
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}).await??;
|
||||
|
||||
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();
|
||||
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
let file = fs::File::open(&archive_path)?;
|
||||
let mut archive = zip::ZipArchive::new(file)?;
|
||||
|
||||
let total_entries = archive.len();
|
||||
info!("ZIP archive contains {} entries", total_entries);
|
||||
|
||||
for i in 0..total_entries {
|
||||
let mut file = archive.by_index(i)?;
|
||||
let outpath = match file.enclosed_name() {
|
||||
Some(path) => output_dir.join(path),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Update progress
|
||||
let progress = (i + 1) as f32 / total_entries as f32;
|
||||
let filename = outpath.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or("unknown");
|
||||
|
||||
if (*file.name()).ends_with('/') {
|
||||
fs::create_dir_all(&outpath)?;
|
||||
} else {
|
||||
if let Some(p) = outpath.parent() {
|
||||
if !p.exists() {
|
||||
fs::create_dir_all(p)?;
|
||||
}
|
||||
}
|
||||
let mut outfile = fs::File::create(&outpath)?;
|
||||
std::io::copy(&mut file, &mut outfile)?;
|
||||
}
|
||||
|
||||
// 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))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Update progress on the tokio runtime
|
||||
let progress_callback_clone = progress_callback.clone();
|
||||
tokio::runtime::Handle::current().block_on(async move {
|
||||
let callback = progress_callback_clone.lock().await;
|
||||
callback(progress, filename);
|
||||
});
|
||||
}
|
||||
|
||||
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: &str,
|
||||
ee_exe_path: Option<&Path>
|
||||
) -> Result<()> {
|
||||
// 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")?;
|
||||
|
||||
// Extract main content
|
||||
self.extract_7z(content_archive, install_path).await?;
|
||||
|
||||
// Handle game-specific files
|
||||
if game_type.to_lowercase() == "diamond" {
|
||||
// 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");
|
||||
tokio::fs::copy(launcher, &target_path).await?;
|
||||
|
||||
} else if game_type.to_lowercase() == "enhancededition" || game_type.to_lowercase() == "ee" {
|
||||
// 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").join("win32_8181");
|
||||
|
||||
#[cfg(unix)]
|
||||
let extract_dir = ee_path.join("bin").join("linux_8181");
|
||||
|
||||
// Extract the launcher
|
||||
self.extract_zip(launcher_zip, &extract_dir).await?;
|
||||
} else {
|
||||
return Err(anyhow::anyhow!("EE executable path is required for EE installation"));
|
||||
}
|
||||
}
|
||||
|
||||
info!("All files installed successfully");
|
||||
Ok(())
|
||||
}
|
||||
}
|
5
rust/src/installer/mod.rs
Normal file
5
rust/src/installer/mod.rs
Normal file
@ -0,0 +1,5 @@
|
||||
pub mod downloader;
|
||||
pub mod downloaderv2;
|
||||
pub mod extractor;
|
||||
pub mod validator;
|
||||
pub mod shortcut;
|
64
rust/src/installer/shortcut.rs
Normal file
64
rust/src/installer/shortcut.rs
Normal file
@ -0,0 +1,64 @@
|
||||
pub mod shortcut {
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::io::Write;
|
||||
use anyhow::Result;
|
||||
|
||||
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(())
|
||||
}
|
||||
}
|
19
rust/src/installer/validator.rs
Normal file
19
rust/src/installer/validator.rs
Normal file
@ -0,0 +1,19 @@
|
||||
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();
|
||||
}
|
||||
}
|
51
rust/src/main.rs
Normal file
51
rust/src/main.rs
Normal file
@ -0,0 +1,51 @@
|
||||
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
|
||||
}
|
23
rust/src/ui/download_progress.rs
Normal file
23
rust/src/ui/download_progress.rs
Normal file
@ -0,0 +1,23 @@
|
||||
pub mod download_progress {
|
||||
use eframe::egui::{Ui, ProgressBar};
|
||||
use crate::app::SinfarInstallerApp;
|
||||
|
||||
pub fn render(ui: &mut Ui, app: &mut SinfarInstallerApp) {
|
||||
ui.heading("Downloading Sinfar Custom Content");
|
||||
ui.add_space(10.0);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Downloading:");
|
||||
ui.add(ProgressBar::new(app.download_progress).show_percentage());
|
||||
});
|
||||
|
||||
if let Some(error) = &app.download_error {
|
||||
ui.colored_label(eframe::egui::Color32::RED, format!("Error: {}", error));
|
||||
if ui.button("Retry").clicked() {
|
||||
app.download_error = None;
|
||||
app.download_progress = 0.0;
|
||||
app.start_download();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
38
rust/src/ui/game_selection.rs
Normal file
38
rust/src/ui/game_selection.rs
Normal file
@ -0,0 +1,38 @@
|
||||
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.)"),
|
||||
}
|
||||
}
|
||||
}
|
22
rust/src/ui/installation_progress.rs
Normal file
22
rust/src/ui/installation_progress.rs
Normal file
@ -0,0 +1,22 @@
|
||||
pub mod installation_progress {
|
||||
use eframe::egui::{Ui, ProgressBar};
|
||||
use crate::app::SinfarInstallerApp;
|
||||
|
||||
pub fn render(ui: &mut Ui, app: &mut SinfarInstallerApp) {
|
||||
ui.heading("Installing Sinfar Custom Content");
|
||||
ui.add_space(10.0);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Extracting files:");
|
||||
ui.add(ProgressBar::new(app.extraction_progress).show_percentage());
|
||||
});
|
||||
|
||||
if let Some(error) = &app.install_error {
|
||||
ui.colored_label(eframe::egui::Color32::RED, error);
|
||||
if ui.button("Retry").clicked() {
|
||||
app.install_error = None;
|
||||
app.start_installation();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
4
rust/src/ui/mod.rs
Normal file
4
rust/src/ui/mod.rs
Normal file
@ -0,0 +1,4 @@
|
||||
pub mod game_selection;
|
||||
pub mod path_selection;
|
||||
pub mod download_progress;
|
||||
pub mod installation_progress;
|
105
rust/src/ui/path_selection.rs
Normal file
105
rust/src/ui/path_selection.rs
Normal file
@ -0,0 +1,105 @@
|
||||
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");
|
||||
}
|
||||
}
|
1
rust/src/util/mod.rs
Normal file
1
rust/src/util/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod platform;
|
280
rust/src/util/platform.rs
Normal file
280
rust/src/util/platform.rs
Normal file
@ -0,0 +1,280 @@
|
||||
// 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(())
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user