Compare commits

...

5 commits
0.1.8 ... main

Author SHA1 Message Date
Yuhki
118b4f5fe0
Update README.md 2025-05-30 23:20:09 +02:00
Yuhki
29bd052ef4 chore: add package.sh script and apply code formatting 2025-05-30 23:18:05 +02:00
Yuhki
bece26c433 Add support for resuming partial downloads using HTTP Range requests
- Modified `download_single_file` to check for an existing partially downloaded file
- If present, uses HTTP Range headers to resume the download from the last byte
- Opens file in append mode rather than overwriting it
- Handles HTTP 206 Partial Content and 416 Range Not Satisfiable responses
- Updates progress bar and download state accordingly during resumed downloads
2025-05-27 18:27:30 +02:00
Yuhki
cc837ec0cf Add support to fetch and display game versions in selection menu 2025-05-26 07:13:10 +02:00
Yuhki
f6477359e9 Fix unused import warnings for windows 2025-05-24 03:00:33 +02:00
12 changed files with 266 additions and 38 deletions

3
.gitignore vendored
View file

@ -4,6 +4,9 @@ debug/
target/ target/
.idea/ .idea/
# packaging
dist/
# These are backup files generated by rustfmt # These are backup files generated by rustfmt
**/*.rs.bk **/*.rs.bk

View file

@ -10,6 +10,7 @@
## 📦 Requirements ## 📦 Requirements
- **Rust nightly toolchain** - 1.87.0-nightly or newer - **Rust nightly toolchain** - 1.87.0-nightly or newer
- **Windows** - for full console feature support - **Windows** - for full console feature support
- **Linux** - for simply being better
### 🛠️ Installation & Usage ### 🛠️ Installation & Usage
- **Install the nightly toolchain:** - **Install the nightly toolchain:**

113
package.sh Normal file
View file

@ -0,0 +1,113 @@
#!/bin/bash
set -e
BIN_NAME="wuwa-downloader"
DIST_DIR="dist"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
CYAN='\033[0;36m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
function info() {
echo -e "${CYAN}==> $1${NC}"
}
function success() {
echo -e "${GREEN}$1${NC}"
}
function warn() {
echo -e "${YELLOW}$1${NC}"
}
function error() {
echo -e "${RED}$1${NC}"
}
function build_linux() {
local OUT_DIR="target/release"
local PACKAGE_NAME="${BIN_NAME}-linux-x86_64"
local PACKAGE_DIR="${DIST_DIR}/${PACKAGE_NAME}"
local ARCHIVE_NAME="${PACKAGE_NAME}.tar.gz"
clear
info "Cleaning binaries to rebuild..."
cargo clean
info "Building release binary for Linux..."
cargo build --release
clear
success "Linux build finished"
info "Creating package directory..."
rm -rf "$PACKAGE_DIR"
mkdir -p "$PACKAGE_DIR"
success "Package directory ready: $PACKAGE_DIR"
info "Copying binary..."
cp "$OUT_DIR/$BIN_NAME" "$PACKAGE_DIR/"
success "Copied binary to package directory"
info "Creating archive..."
cd "$DIST_DIR"
tar -czf "$ARCHIVE_NAME" "$PACKAGE_NAME"
cd -
success "Archive created: ${DIST_DIR}/${ARCHIVE_NAME}"
}
function build_windows() {
local TARGET="x86_64-pc-windows-gnu"
local OUT_DIR="target/${TARGET}/release"
local PACKAGE_NAME="${BIN_NAME}-windows-x86_64"
local PACKAGE_DIR="${DIST_DIR}/${PACKAGE_NAME}"
local ARCHIVE_NAME="${PACKAGE_NAME}.zip"
clear
info "Cleaning binaries to rebuild..."
cargo clean
info "Building release binary for Windows..."
cargo build --release --target "$TARGET"
clear
success "Windows build finished"
info "Creating package directory..."
rm -rf "$PACKAGE_DIR"
mkdir -p "$PACKAGE_DIR"
success "Package directory ready: $PACKAGE_DIR"
info "Copying binary..."
cp "$OUT_DIR/${BIN_NAME}.exe" "$PACKAGE_DIR/"
success "Copied binary to package directory"
info "Creating archive..."
cd "$DIST_DIR"
zip -r "$ARCHIVE_NAME" "$PACKAGE_NAME"
cd -
success "Archive created: ${DIST_DIR}/${ARCHIVE_NAME}"
}
if [[ $# -ne 1 ]]; then
error "Usage: $0 [linux|windows]"
exit 1
fi
case "$1" in
linux)
build_linux
;;
windows)
build_windows
;;
*)
error "Unknown target: $1"
error "Usage: $0 [linux|windows]"
exit 1
;;
esac

View file

@ -4,11 +4,25 @@ use colored::*;
pub struct Status; pub struct Status;
impl Status { impl Status {
pub fn info() -> ColoredString { "[*]".cyan() } pub fn info() -> ColoredString {
pub fn success() -> ColoredString { "[+]".green() } "[*]".cyan()
pub fn warning() -> ColoredString { "[!]".yellow() } }
pub fn error() -> ColoredString { "[-]".red() } pub fn success() -> ColoredString {
pub fn question() -> ColoredString { "[?]".blue() } "[+]".green()
pub fn progress() -> ColoredString { "[→]".purple() } }
pub fn matched() -> ColoredString { "[↓]".bright_purple() } pub fn warning() -> ColoredString {
"[!]".yellow()
}
pub fn error() -> ColoredString {
"[-]".red()
}
pub fn question() -> ColoredString {
"[?]".blue()
}
pub fn progress() -> ColoredString {
"[→]".purple()
}
pub fn matched() -> ColoredString {
"[↓]".bright_purple()
}
} }

View file

@ -1,6 +1,6 @@
use std::{io, path::Path};
use colored::Colorize;
use crate::config::status::Status; use crate::config::status::Status;
use colored::Colorize;
use std::{io, path::Path};
pub fn print_results(success: usize, total: usize, folder: &Path) { pub fn print_results(success: usize, total: usize, folder: &Path) {
let title = if success == total { let title = if success == total {

View file

@ -1,5 +1,8 @@
use std::{fs::{self, OpenOptions}, io::Write, time::SystemTime}; use std::{
fs::{self, OpenOptions},
io::Write,
time::SystemTime,
};
pub fn setup_logging() -> fs::File { pub fn setup_logging() -> fs::File {
OpenOptions::new() OpenOptions::new()

View file

@ -2,8 +2,16 @@ use colored::Colorize;
use reqwest::blocking::Client; use reqwest::blocking::Client;
use serde_json::Value; use serde_json::Value;
use std::{ use std::{
fs::{self, File}, io::{self, Write}, process::Command, sync::Arc, thread, time::{Duration, Instant} fs::{self, File},
io::{self, Write},
sync::Arc,
thread,
time::{Duration, Instant},
}; };
#[cfg(not(target_os = "windows"))]
use std::process::Command;
#[cfg(windows)] #[cfg(windows)]
use winconsole::console::{clear, set_title}; use winconsole::console::{clear, set_title};

View file

@ -1,8 +1,10 @@
use std::process::Command;
use colored::*; use colored::*;
use reqwest::blocking::Client; use reqwest::blocking::Client;
use serde_json::Value; use serde_json::Value;
#[cfg(not(target_os = "windows"))]
use std::process::Command;
#[cfg(windows)] #[cfg(windows)]
use winconsole::console::{clear, set_title}; use winconsole::console::{clear, set_title};
@ -11,10 +13,11 @@ use wuwa_downloader::{
io::{ io::{
console::print_results, console::print_results,
file::get_dir, file::get_dir,
util::{
calculate_total_size, download_resources, exit_with_error, setup_ctrlc, track_progress, start_title_thread
},
logging::setup_logging, logging::setup_logging,
util::{
calculate_total_size, download_resources, exit_with_error, setup_ctrlc,
start_title_thread, track_progress,
},
}, },
network::client::{fetch_index, get_config}, network::client::{fetch_index, get_config},
}; };

View file

@ -2,10 +2,17 @@ use colored::Colorize;
use flate2::read::GzDecoder; use flate2::read::GzDecoder;
use indicatif::{ProgressBar, ProgressStyle}; use indicatif::{ProgressBar, ProgressStyle};
use reqwest::blocking::Client; use reqwest::blocking::Client;
use serde_json::{Value, from_reader, from_str}; use serde_json::{from_reader, from_str, Value};
#[cfg(not(target_os = "windows"))]
use std::process::Command;
use std::{ use std::{
fs, io::{self, Read, Write}, path::Path, process::Command, time::Duration, u64 fs::{self, OpenOptions},
io::{self, Read, Write},
path::Path,
time::Duration,
u64,
}; };
#[cfg(windows)] #[cfg(windows)]
use winconsole::console::clear; use winconsole::console::clear;
@ -323,20 +330,46 @@ fn download_single_file(
progress: &DownloadProgress, progress: &DownloadProgress,
pb: &ProgressBar, pb: &ProgressBar,
) -> Result<(), String> { ) -> Result<(), String> {
let mut response = client let mut downloaded: u64 = 0;
.get(url) if path.exists() {
.timeout(DOWNLOAD_TIMEOUT) downloaded = fs::metadata(path)
.map_err(|e| format!("Metadata error: {}", e))?
.len();
}
let request = client.get(url).timeout(DOWNLOAD_TIMEOUT);
let request = if downloaded > 0 {
request.header("Range", format!("bytes={}-", downloaded))
} else {
request
};
let mut response = request
.send() .send()
.map_err(|e| format!("Network error: {}", e))?; .map_err(|e| format!("Network error: {}", e))?;
if !response.status().is_success() { if response.status() == reqwest::StatusCode::RANGE_NOT_SATISFIABLE {
return Err("Range not satisfiable. File may already be fully downloaded.".into());
}
if !response.status().is_success() && response.status() != reqwest::StatusCode::PARTIAL_CONTENT
{
return Err(format!("HTTP error: {}", response.status())); return Err(format!("HTTP error: {}", response.status()));
} }
let mut file = fs::File::create(path).map_err(|e| format!("File error: {}", e))?; let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map_err(|e| format!("File error: {}", e))?;
pb.set_position(downloaded);
progress
.downloaded_bytes
.store(downloaded, std::sync::atomic::Ordering::SeqCst);
let mut buffer = [0; BUFFER_SIZE]; let mut buffer = [0; BUFFER_SIZE];
let mut downloaded: u64 = 0;
loop { loop {
if should_stop.load(std::sync::atomic::Ordering::SeqCst) { if should_stop.load(std::sync::atomic::Ordering::SeqCst) {
return Err("Download interrupted".into()); return Err("Download interrupted".into());
@ -357,7 +390,7 @@ fn download_single_file(
pb.set_position(downloaded); pb.set_position(downloaded);
progress progress
.downloaded_bytes .downloaded_bytes
.fetch_add(bytes_read as u64, std::sync::atomic::Ordering::SeqCst); .store(downloaded, std::sync::atomic::Ordering::SeqCst);
} }
Ok(()) Ok(())
@ -518,12 +551,10 @@ pub fn fetch_gist(client: &Client) -> Result<String, String> {
response response
.copy_to(&mut buffer) .copy_to(&mut buffer)
.map_err(|e| format!("Error reading response: {}", e))?; .map_err(|e| format!("Error reading response: {}", e))?;
let mut gz = GzDecoder::new(&buffer[..]); let mut gz = GzDecoder::new(&buffer[..]);
let mut decompressed = String::new(); let mut decompressed = String::new();
gz.read_to_string(&mut decompressed) gz.read_to_string(&mut decompressed)
.map_err(|e| format!("Error decompressing: {}", e))?; .map_err(|e| format!("Error decompressing: {}", e))?;
from_str(&decompressed).map_err(|e| format!("Invalid JSON: {}", e))? from_str(&decompressed).map_err(|e| format!("Invalid JSON: {}", e))?
} else { } else {
from_reader(response).map_err(|e| format!("Invalid JSON: {}", e))? from_reader(response).map_err(|e| format!("Invalid JSON: {}", e))?
@ -532,11 +563,63 @@ pub fn fetch_gist(client: &Client) -> Result<String, String> {
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
Command::new("clear").status().unwrap(); Command::new("clear").status().unwrap();
let entries = [
("live", "os", "Live - OS"),
("live", "cn", "Live - CN"),
("beta", "os", "Beta - OS"),
("beta", "cn", "Beta - CN"),
];
println!("{} Available versions:", Status::info()); println!("{} Available versions:", Status::info());
println!("1. Live - OS");
println!("2. Live - CN"); for (i, (cat, ver, label)) in entries.iter().enumerate() {
println!("3. Beta - OS"); let index_url = get_version(&gist_data, cat, ver)?;
println!("4. Beta - CN (wicked-waifus-rs)");
let mut resp = client
.get(&index_url)
.send()
.map_err(|e| format!("Error fetching index.json: {}", e))
.unwrap();
let version_json: Value = {
let content_encoding = resp
.headers()
.get("content-encoding")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if content_encoding.contains("gzip") {
let mut buffer = Vec::new();
resp.copy_to(&mut buffer)
.map_err(|e| format!("Error reading response: {}", e))
.unwrap();
let mut gz = GzDecoder::new(&buffer[..]);
let mut decompressed = String::new();
gz.read_to_string(&mut decompressed)
.map_err(|e| format!("Error decompressing: {}", e))
.unwrap();
from_str(&decompressed)
.map_err(|e| format!("Invalid JSON: {}", e))
.unwrap()
} else {
from_reader(resp)
.map_err(|e| format!("Invalid JSON: {}", e))
.unwrap()
}
};
let version = version_json
.get("default")
.and_then(|d| d.get("config"))
.and_then(|c| c.get("version"))
.or_else(|| version_json.get("default").and_then(|d| d.get("version")))
.and_then(|v| v.as_str())
.unwrap_or("unknown");
println!("{}. {} ({})", i + 1, label, version);
}
loop { loop {
print!("{} Select version: ", Status::question()); print!("{} Select version: ", Status::question());