Compare commits
No commits in common. "main" and "1.0.2" have entirely different histories.
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,4 +1 @@
|
||||
SinfarInstaller-v30.exe
|
||||
win32_8181.7z
|
||||
sinfar_all_files_v30.7z
|
||||
rust/target
|
||||
|
@ -1,93 +0,0 @@
|
||||
|
||||
!include "nsDialogs.nsh"
|
||||
|
||||
Var PathTextBoxHandle
|
||||
Var EEEXEPathTextBoxHandle
|
||||
Var EEButtonHandle
|
||||
|
||||
Function Page_SelectNWNPath
|
||||
|
||||
nsDialogs::Create 1018
|
||||
Pop $0
|
||||
|
||||
StrCmp $INSTALL_TYPE "diamond" diamond_select_path ee_select_path
|
||||
ee_select_path:
|
||||
StrCpy $INSTPATH "$DOCUMENTS\Neverwinter Nights"
|
||||
Goto selectpath_end
|
||||
diamond_select_path:
|
||||
StrCpy $INSTPATH "C:\Program Files (x86)\GOG Galaxy\Games\NWN Diamond"
|
||||
Goto selectpath_end
|
||||
selectpath_end:
|
||||
|
||||
;________________________________________
|
||||
; Create label above
|
||||
${NSD_CreateLabel} 0 0 100% 12u "Please select your Neverwinter Nights install folder:"
|
||||
Pop $1
|
||||
; Create a read-only text box to display the selected path
|
||||
${NSD_CreateText} 0 14u 75% 12u "$INSTPATH"
|
||||
Pop $PathTextBoxHandle
|
||||
; Create a "Browse..." button
|
||||
${NSD_CreateButton} 80% 14u 20% 12u "Browse..."
|
||||
Pop $2
|
||||
${NSD_OnClick} $2 OnBrowseClicked
|
||||
;________________________________________
|
||||
|
||||
StrCmp $INSTALL_TYPE "diamond" skipee_exe
|
||||
StrCpy $EE_EXEPATH "C:\Program Files (x86)\GOG Galaxy\Games\Neverwinter Nights Enhanced Edition"
|
||||
; Create the label
|
||||
${NSD_CreateLabel} 0 32u 100% 12u "Select the location of the nwn exectable for EE:"
|
||||
Pop $2
|
||||
; Create the text
|
||||
${NSD_CreateText} 0 44u 75% 12u "$EE_EXEPATH"
|
||||
Pop $EEEXEPathTextBoxHandle
|
||||
; CReate the button
|
||||
${NSD_CreateButton} 80% 44u 20% 12u "Browse..."
|
||||
Pop $EEButtonHandle
|
||||
${NSD_OnClick} $EEButtonHandle OnBrowse2Clicked
|
||||
${NSD_CreateLabel} 0 58u 100% 24u "(Path may differ for steam ex: C:\Program Files (x86)\Steam\steamapps\common\Neverwinter Nights)"
|
||||
;________________________________________
|
||||
|
||||
skipee_exe:
|
||||
|
||||
nsDialogs::Show
|
||||
FunctionEnd
|
||||
|
||||
Function OnBrowseClicked
|
||||
nsDialogs::SelectFolderDialog "Select Neverwinter Nights Folder" "$INSTPATH"
|
||||
Pop $0
|
||||
StrCmp $0 "" done
|
||||
StrCmp $0 "error" done
|
||||
StrCpy $INSTPATH "$0"
|
||||
${NSD_SetText} $PathTextBoxHandle $INSTPATH
|
||||
done:
|
||||
FunctionEnd
|
||||
|
||||
Function OnBrowse2Clicked
|
||||
nsDialogs::SelectFolderDialog "Select Neverwinter Nights executable location" "$EE_EXEPATH"
|
||||
Pop $0
|
||||
StrCmp $0 "" done
|
||||
StrCmp $0 "error" done
|
||||
StrCpy $EE_EXEPATH "$0"
|
||||
${NSD_SetText} $EEEXEPathTextBoxHandle $EE_EXEPATH
|
||||
done:
|
||||
FunctionEnd
|
||||
|
||||
Function Page_SelectNWNPath_Validation
|
||||
; Get the current text in the folder path textbox
|
||||
${NSD_GetText} $PathTextBoxHandle $INSTPATH
|
||||
|
||||
; Check if tlk and hak folder exists
|
||||
IfFileExists "$INSTPATH\tlk\*.*" 0 invalid
|
||||
IfFileExists "$INSTPATH\hak\*.*" 0 invalid
|
||||
|
||||
StrCmp $INSTALL_TYPE "diamond" skipee_exe_validation
|
||||
; IF EE Gotta validate for the exetucable's presence!!
|
||||
IfFileExists "$EE_EXEPATH\bin\win32" 0 invalid
|
||||
skipee_exe_validation:
|
||||
; Everything is valid
|
||||
Return
|
||||
|
||||
invalid:
|
||||
MessageBox MB_ICONEXCLAMATION|MB_OK "The selected folder must contain both 'tlk' and 'hak' subfolders."
|
||||
Abort ; Prevents advancing to next page
|
||||
FunctionEnd
|
@ -1,26 +0,0 @@
|
||||
|
||||
; The stuff to install
|
||||
Section "Installing haks and tlk" ;No components page, name is not important
|
||||
SetOutPath $INSTPATH
|
||||
DetailPrint "Extracting custom content..."
|
||||
|
||||
;Nsis7z::ExtractWithDetails "$EXEDIR\sinfar_all_files_v30.7z" "Installing package %s..."
|
||||
|
||||
DetailPrint "Custom content extraction complete."
|
||||
|
||||
; DetailPrint "Deleting temporary files."
|
||||
; Delete "$INSTPATH\sinfarFiles.7z"
|
||||
SectionEnd ; end the section
|
||||
|
||||
|
||||
Section "Installing haks and tlk" ;No components page, name is not important
|
||||
|
||||
StrCmp $INSTALL_TYPE "diamond" skip_ee_extract
|
||||
SetOutPath "$EE_EXEPATH\bin\win32_8181"
|
||||
DetailPrint "Extracting Sinfare EE 8181..."
|
||||
File "win32_8181.7z"
|
||||
Nsis7z::ExtractWithDetails "win32_8181.7z" "Installing Sinfer EE v8181 %s..."
|
||||
|
||||
skip_ee_extract:
|
||||
SectionEnd ; end the section
|
||||
|
@ -23,10 +23,10 @@ diamond_download:
|
||||
Goto after_download
|
||||
|
||||
ee_download:
|
||||
; inetc::get /CAPTION "Downloading Sinfar EE Launcher" /POPUP "Sinfarx launcher" /QUESTION "" "https://nwn.sinfar.net/files/sinfarx/8181/win32_8181.zip" "$EE_EXEPATH\sinfarLauncher.zip"
|
||||
; Pop $0
|
||||
; StrCmp $0 "OK" +2
|
||||
; Goto download_failed
|
||||
inetc::get /CAPTION "Downloading Sinfar EE Launcher" /POPUP "Sinfarx launcher" /QUESTION "" "https://nwn.sinfar.net/files/sinfarx_ee.exe" "$INSTPATH\sinfarx.exe"
|
||||
Pop $0
|
||||
StrCmp $0 "OK" +2
|
||||
Goto download_failed
|
||||
|
||||
after_download:
|
||||
|
@ -1,7 +1,7 @@
|
||||
!include "nsDialogs.nsh"
|
||||
|
||||
Var InstallTypeRadio_NWNDIAMOND
|
||||
Var InstallTypeRadio_NWNEE
|
||||
;Var InstallTypeRadio_NWNEE
|
||||
|
||||
Function Page_GameSelection
|
||||
nsDialogs::Create 1018
|
||||
@ -13,10 +13,10 @@ Function Page_GameSelection
|
||||
${NSD_CreateRadioButton} 0 20u 100% 10u "Neverwinter Nights Diamond Edition"
|
||||
Pop $InstallTypeRadio_NWNDIAMOND
|
||||
|
||||
${NSD_CreateRadioButton} 0 32u 100% 10u "Neverwinter Nights: Enhanced Edition"
|
||||
Pop $InstallTypeRadio_NWNEE
|
||||
; ${NSD_CreateRadioButton} 0 32u 100% 10u "Neverwinter Nights: Enhanced Edition"
|
||||
; Pop $InstallTypeRadio_NWNEE
|
||||
|
||||
${NSD_SetState} $InstallTypeRadio_NWNDIAMOND ${BST_CHECKED}
|
||||
${NSD_SetState} $InstallTypeRadio_NWNDIAMOND ${BST_CHECKED}
|
||||
|
||||
nsDialogs::Show
|
||||
FunctionEnd
|
51
Pages/page_select_path.nsi
Normal file
51
Pages/page_select_path.nsi
Normal file
@ -0,0 +1,51 @@
|
||||
|
||||
!include "nsDialogs.nsh"
|
||||
|
||||
Var PathTextBoxHandle
|
||||
|
||||
Function Page_SelectNWNPath
|
||||
|
||||
StrCpy $INSTPATH "C:\Program Files (x86)\GOG Galaxy\Games\NWN Diamond"
|
||||
|
||||
nsDialogs::Create 1018
|
||||
Pop $0
|
||||
|
||||
${NSD_CreateLabel} 0 0 100% 12u "Please select your Neverwinter Nights install folder:"
|
||||
Pop $1
|
||||
|
||||
; Create a read-only text box to display the selected path
|
||||
${NSD_CreateText} 0 14u 75% 12u "$INSTPATH"
|
||||
Pop $PathTextBoxHandle
|
||||
|
||||
; Create a "Browse..." button
|
||||
${NSD_CreateButton} 80% 14u 20% 12u "Browse..."
|
||||
Pop $2
|
||||
${NSD_OnClick} $2 OnBrowseClicked
|
||||
|
||||
nsDialogs::Show
|
||||
FunctionEnd
|
||||
|
||||
Function OnBrowseClicked
|
||||
nsDialogs::SelectFolderDialog "Select Neverwinter Nights Folder" "$INSTPATH"
|
||||
Pop $0
|
||||
StrCmp $0 "" done
|
||||
StrCpy $INSTPATH "$0"
|
||||
${NSD_SetText} $PathTextBoxHandle $INSTPATH
|
||||
done:
|
||||
FunctionEnd
|
||||
|
||||
Function Page_SelectNWNPath_Validation
|
||||
; Get the current text in the folder path textbox
|
||||
${NSD_GetText} $PathTextBoxHandle $INSTPATH
|
||||
|
||||
; Check if tlk and hak folder exists
|
||||
IfFileExists "$INSTPATH\tlk\*.*" 0 invalid
|
||||
IfFileExists "$INSTPATH\hak\*.*" 0 invalid
|
||||
|
||||
; Everything is valid
|
||||
Return
|
||||
|
||||
invalid:
|
||||
MessageBox MB_ICONEXCLAMATION|MB_OK "The selected folder must contain both 'tlk' and 'hak' subfolders."
|
||||
Abort ; Prevents advancing to next page
|
||||
FunctionEnd
|
14
Sections/section_extract_files.nsi
Normal file
14
Sections/section_extract_files.nsi
Normal file
@ -0,0 +1,14 @@
|
||||
|
||||
; The stuff to install
|
||||
Section "Installing" ;No components page, name is not important
|
||||
SetOutPath $INSTPATH
|
||||
DetailPrint "Extracting package..."
|
||||
|
||||
Nsis7z::ExtractWithDetails "$EXEDIR\sinfar_all_files_v30.7z" "Installing package %s..."
|
||||
|
||||
DetailPrint "Extraction complete."
|
||||
|
||||
DetailPrint "Deleting temporary files."
|
||||
; Delete "$INSTPATH\sinfarFiles.7z"
|
||||
SectionEnd ; end the section
|
||||
|
Before Width: | Height: | Size: 220 KiB After Width: | Height: | Size: 220 KiB |
@ -14,7 +14,6 @@ InstallDir "$PROGRAMFILES\DummyInstall"
|
||||
|
||||
Var INSTALL_TYPE
|
||||
Var INSTPATH
|
||||
Var EE_EXEPATH
|
||||
|
||||
;Pages
|
||||
!include "Pages\page_select_path.nsi"
|
||||
@ -28,6 +27,14 @@ Page custom Page_SelectNWNPath Page_SelectNWNPath_Validation ; Custom page first
|
||||
Page custom Page_DownloadContent "" ; Download page
|
||||
Page InstFiles ; Then show Install progress
|
||||
|
||||
Function .onInit
|
||||
SetDetailsView show ; ensure log is visible
|
||||
DetailPrint "==============================="
|
||||
DetailPrint "Sinfar Installer v30"
|
||||
DetailPrint "==============================="
|
||||
DetailPrint "Initializing..."
|
||||
FunctionEnd
|
||||
|
||||
Section "Create Shortcut"
|
||||
DetailPrint "Creating desktop shortcut."
|
||||
File "UIContent\installerico.ico"
|
47
readme.md
47
readme.md
@ -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
4795
rust/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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 |
397
rust/src/app.rs
397
rust/src/app.rs
@ -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));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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(())
|
||||
}
|
||||
}
|
@ -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;
|
@ -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()
|
||||
}
|
||||
}
|
@ -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(())
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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.)"),
|
||||
}
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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");
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
pub mod platform;
|
@ -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(())
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user