use std::io::{self, Write, Read, Cursor};
use std::path::{Path, PathBuf};
use std::process::Command;
use serde::Deserialize;
const GITHUB_API: &str = "https://api.github.com/repos/HuginCyber/Hugin/releases/latest";
const USER_AGENT: &str = "hugincyber-installer/0.1.3";
#[derive(Deserialize)]
struct Release {
tag_name: String,
assets: Vec<Asset>,
}
#[derive(Deserialize)]
struct Asset {
name: String,
browser_download_url: String,
size: u64,
}
fn detect_asset() -> Result<&'static str, String> {
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;
match (os, arch) {
("linux", "x86_64") => Ok("hugin-cli-linux-x86_64.tar.gz"),
("macos", "aarch64") => Ok("hugin-cli-darwin-aarch64.tar.gz"),
("macos", "x86_64") => Ok("hugin-cli-darwin-x86_64.tar.gz"),
("linux", "aarch64") => Err("Linux aarch64 builds are not yet available. Star the repo to vote for it.".into()),
("windows", _) => Err("Windows is not supported yet. Hugin runs on Linux and macOS.".into()),
(os, arch) => Err(format!("Unsupported platform: {os}-{arch}. Hugin runs on linux-x86_64, macos-aarch64, and macos-x86_64.")),
}
}
fn install_dir() -> Result<PathBuf, String> {
if let Some(home) = std::env::var_os("HOME") {
let local_bin = Path::new(&home).join(".local").join("bin");
if local_bin.exists() || std::fs::create_dir_all(&local_bin).is_ok() {
return Ok(local_bin);
}
}
if std::env::consts::OS == "macos" {
let usr_local = Path::new("/usr/local/bin");
if usr_local.exists() {
return Ok(usr_local.to_path_buf());
}
}
Err("Could not find a writable install directory. Set HOME or ensure ~/.local/bin or /usr/local/bin is writable.".into())
}
fn fetch_release() -> Result<Release, String> {
let response = reqwest::blocking::Client::builder()
.user_agent(USER_AGENT)
.build()
.map_err(|e| format!("Failed to build HTTP client: {e}"))?
.get(GITHUB_API)
.header("Accept", "application/vnd.github+json")
.send()
.map_err(|e| format!("Failed to fetch release info: {e}"))?;
if !response.status().is_success() {
return Err(format!(
"GitHub API returned HTTP {}: {}",
response.status().as_u16(),
response.status().canonical_reason().unwrap_or("error")
));
}
response
.json::<Release>()
.map_err(|e| format!("Failed to parse release JSON: {e}"))
}
fn download_asset(url: &str) -> Result<Vec<u8>, String> {
print!(" Downloading... ");
io::stdout().flush().ok();
let response = reqwest::blocking::Client::builder()
.user_agent(USER_AGENT)
.build()
.map_err(|e| format!("Failed to build HTTP client: {e}"))?
.get(url)
.send()
.map_err(|e| format!("Failed to download: {e}"))?;
if !response.status().is_success() {
return Err(format!("Download failed: HTTP {}", response.status()));
}
let bytes = response
.bytes()
.map_err(|e| format!("Failed to read download: {e}"))?;
println!("{} MB", bytes.len() / 1_048_576);
Ok(bytes.to_vec())
}
fn extract_hugin(archive: &[u8], dest: &Path) -> Result<PathBuf, String> {
let cursor = Cursor::new(archive);
let gz = flate2::read::GzDecoder::new(cursor);
let mut tar = tar::Archive::new(gz);
for entry in tar.entries().map_err(|e| format!("Failed to read tar: {e}"))? {
let mut entry = entry.map_err(|e| format!("Failed to read tar entry: {e}"))?;
let name = entry
.path()
.map_err(|e| format!("Failed to get entry path: {e}"))?
.into_owned();
if name.file_name() == Some(std::ffi::OsStr::new("hugin")) {
let dest_path = dest.join("hugin");
entry
.unpack(dest)
.map_err(|e| format!("Failed to extract hugin: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&dest_path, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("Failed to set executable permissions: {e}"))?;
}
return Ok(dest_path);
}
}
Err("hugin binary not found in archive".into())
}
fn is_on_path(dir: &Path) -> bool {
if let Ok(path_var) = std::env::var("PATH") {
for entry in path_var.split(':') {
if Path::new(entry) == dir {
return true;
}
}
}
false
}
fn main() {
let version = env!("CARGO_PKG_VERSION");
println!("\n \x1b[1;38;5;208mHuginCyber\x1b[0m installer v{version}\n");
println!(" The security intercepting proxy for hackers.");
println!(" https://hugin.nu\n");
let asset_name = match detect_asset() {
Ok(name) => name,
Err(e) => {
eprintln!("\n \x1b[31mError:\x1b[0m {e}");
std::process::exit(1);
}
};
print!(" Checking latest release... ");
io::stdout().flush().ok();
let release = match fetch_release() {
Ok(r) => {
println!("{}", r.tag_name);
r
}
Err(e) => {
eprintln!("\n \x1b[31mError:\x1b[0m {e}");
std::process::exit(1);
}
};
let asset = release
.assets
.iter()
.find(|a| a.name == asset_name)
.unwrap_or_else(|| {
eprintln!(
"\n \x1b[31mError:\x1b[0m No matching asset '{asset_name}' in release {}.",
release.tag_name
);
eprintln!(" Available assets:");
for a in &release.assets {
eprintln!(" - {} ({} MB)", a.name, a.size / 1_048_576);
}
std::process::exit(1);
});
let archive = match download_asset(&asset.browser_download_url) {
Ok(data) => data,
Err(e) => {
eprintln!("\n \x1b[31mError:\x1b[0m {e}");
std::process::exit(1);
}
};
let install_dir = match install_dir() {
Ok(dir) => dir,
Err(e) => {
eprintln!("\n \x1b[31mError:\x3b[0m {e}");
std::process::exit(1);
}
};
print!(" Extracting to {}... ", install_dir.display());
io::stdout().flush().ok();
let binary = match extract_hugin(&archive, &install_dir) {
Ok(p) => {
println!("done");
p
}
Err(e) => {
eprintln!("\n \x1b[31mError:\x3b[0m {e}");
std::process::exit(1);
}
};
if let Ok(output) = Command::new(&binary).arg("--version").output() {
let v = String::from_utf8_lossy(&output.stdout);
let v = v.lines().next().unwrap_or("(unknown version)");
println!("\n \x1b[32mInstalled:\x1b[0m {v}");
} else {
println!("\n \x1b[32mInstalled:\x1b[0m {}", binary.display());
}
if !is_on_path(&install_dir) {
eprintln!(
"\n \x1b[33mWarning:\x1b[0m {} is not on your PATH.",
install_dir.display()
);
eprintln!(" Add it to your shell profile:");
eprintln!(" export PATH=\"{}:$PATH\"", install_dir.display());
}
println!("\n Run \x1b[1mhugin\x1b[0m to start.\n");
}