diff --git a/Cargo.toml b/Cargo.toml index 8a97996..c7baf7c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,10 +24,15 @@ winapi = { version = "0.3.9", features = ["winuser", "winbase"] } reqwest = { version = "0.12.8", features = ["json"] } tokio = { version = "1.41.0", features = ["full"] } serde = { version = "1.0.213", features = ["derive"] } +thiserror = "1.0.65" [build-dependencies] winres = "0.1.12" [dev-dependencies] tempfile = "3.13.0" -regex = "1.11.0" \ No newline at end of file +regex = "1.11.1" +mockall = "0.13.0" +wiremock = "0.6.2" +tokio = { version = "1.41.0", features = ["full"] } +serde_json = "1.0.132" \ No newline at end of file diff --git a/src/imhex.rs b/src/imhex.rs index 1b047a5..8996057 100644 --- a/src/imhex.rs +++ b/src/imhex.rs @@ -22,27 +22,42 @@ lazy_static! { static ref PREVIOUS_RUNNING_STATE: Mutex = Mutex::new(false); } +// Logs an error message to a file fn log_error(message: &str) { - let mut log_file_path = home_dir().unwrap_or_else(|| PathBuf::from("C:\\Users\\Default")); - log_file_path.push(".discord-imhex/error.log"); - - let mut file = OpenOptions::new() - .create(true) - .append(true) - .open(log_file_path) - .unwrap(); - let timestamp = current_timestamp(); - writeln!(file, "[{}] {}", timestamp, message).unwrap(); + if let Some(log_file_path) = get_log_file_path() { + if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(log_file_path) { + let timestamp = current_timestamp(); + if let Err(e) = writeln!(file, "[{}] {}", timestamp, message) { + eprintln!("Failed to write to log file: {}", e); + } + } else { + eprintln!("Failed to open log file."); + } + } } +// Gets the file path for the log file +fn get_log_file_path() -> Option { + home_dir().or_else(|| Some(PathBuf::from("C:\\Users\\Default"))).map(|mut path| { + path.push(".discord-imhex/error.log"); + path + }) +} + +// Converts a string to hex fn string_to_hex(s: &str) -> String { if s == "ImHex" { "0".to_string() } else { - s.as_bytes().iter().map(|b| format!("{:02x}", b)).collect::>().join("") + s.as_bytes() + .iter() + .map(|b| format!("{:02x}", b)) + .collect::>() + .join("") } } +// Windows callback function unsafe extern "system" fn enum_windows_proc(hwnd: HWND, lparam: LPARAM) -> i32 { let mut title: [u16; 256] = [0; 256]; let length = GetWindowTextW(hwnd, title.as_mut_ptr(), title.len() as i32); @@ -53,55 +68,67 @@ unsafe extern "system" fn enum_windows_proc(hwnd: HWND, lparam: LPARAM) -> i32 { .into_owned(); if window_title.starts_with("ImHex") || window_title.contains("imhex-gui.exe") { - let mut previous_title = PREVIOUS_TITLE.lock().unwrap(); - if let Some(index) = window_title.find(" - ") { - let current_opened_file = &window_title[(index + 3)..]; - if previous_title.as_deref() != Some(current_opened_file) { - log_error(&format!("Currently opened file: {}", current_opened_file)); - *previous_title = Some(current_opened_file.to_string()); - } - *(lparam as *mut String) = current_opened_file.to_string(); - } else { - if previous_title.as_deref() != Some(&window_title) { - let hex_string = string_to_hex(&window_title); - log_error(&format!("Currently opened file: {}", hex_string)); - *previous_title = Some(window_title.clone()); - } - *(lparam as *mut String) = window_title; - } - + process_window_title(window_title, lparam); return 0; } } 1 } +// Processes the window title && logs +fn process_window_title(window_title: String, lparam: LPARAM) { + let mut previous_title = PREVIOUS_TITLE.lock().unwrap(); + if let Some(index) = window_title.find(" - ") { + let current_opened_file = &window_title[(index + 3)..]; + if previous_title.as_deref() != Some(current_opened_file) { + log_error(&format!("Currently opened file: {}", current_opened_file)); + *previous_title = Some(current_opened_file.to_string()); + } + unsafe { *(lparam as *mut String) = current_opened_file.to_string(); } + } else { + if previous_title.as_deref() != Some(&window_title) { + let hex_string = string_to_hex(&window_title); + log_error(&format!("Currently opened file: {}", hex_string)); + *previous_title = Some(window_title.clone()); + } + unsafe { *(lparam as *mut String) = window_title; } + } +} + +// Checks if an ImHex window exists && returns window title pub fn check_if_imhex_window_exists() -> Option { let mut found = String::new(); unsafe { EnumWindows(Some(enum_windows_proc), &mut found as *mut _ as LPARAM); } if found.is_empty() { - let mut previous_title = PREVIOUS_TITLE.lock().unwrap(); - if previous_title.is_some() { - log_error(&format!("No ImHex window found at {}", Local::now().format("%Y-%m-%d %H:%M:%S"))); - *previous_title = None; - } + handle_no_imhex_window(); None } else { Some(found) } } +// Handles no ImHex window is found +fn handle_no_imhex_window() { + let mut previous_title = PREVIOUS_TITLE.lock().unwrap(); + if previous_title.is_some() { + log_error(&format!("No ImHex window found at {}", Local::now().format("%Y-%m-%d %H:%M:%S"))); + *previous_title = None; + } +} + +// Gets bytes in ImHex pub fn get_selected_bytes() -> Option { if let Some(current_file) = check_if_imhex_window_exists() { let hex_string = string_to_hex(¤t_file); - let bytes: Vec = hex_string - .as_bytes() - .chunks(2) - .map(|chunk| u8::from_str_radix(std::str::from_utf8(chunk).unwrap(), 16).unwrap()) - .collect(); - + let bytes: Vec = match hex_string.as_bytes().chunks(2).map(|chunk| { + u8::from_str_radix(std::str::from_utf8(chunk).unwrap_or_default(), 16).ok() + }).collect::>>() { + Some(v) => v, + None => return None, + }; + if let (Some(&min), Some(&max)) = (bytes.iter().min(), bytes.iter().max()) { return Some(format!("0x{:02X}-0x{:02X}", min, max)); } @@ -109,29 +136,36 @@ pub fn get_selected_bytes() -> Option { None } +// Checks if ImHex is running pub(crate) fn is_imhex_running() -> bool { let output = match Command::new("tasklist") .arg("/FI") .arg("IMAGENAME eq imhex-gui.exe") .creation_flags(CREATE_NO_WINDOW) - .output() { - Ok(output) => output, - Err(e) => { - log_error(&format!("Failed to execute tasklist: {}", e)); - return false; - } - }; + .output() + { + Ok(output) => output, + Err(e) => { + log_error(&format!("Failed to execute tasklist: {}", e)); + return false; + } + }; let output_str = String::from_utf8_lossy(&output.stdout); let is_running = output_str.contains("imhex-gui.exe"); + update_running_state(is_running); + is_running +} + +// Updates the running state +fn update_running_state(is_running: bool) { let mut previous_running_state = PREVIOUS_RUNNING_STATE.lock().unwrap(); if is_running != *previous_running_state { - if is_running { - log_error(&format!("ImHex is running.")); + log_error(if is_running { + "ImHex is running." } else { - log_error(&format!("ImHex is not running.")); - } + "ImHex is not running." + }); *previous_running_state = is_running; } - is_running -} \ No newline at end of file +} diff --git a/src/main.rs b/src/main.rs index 753f834..e4c98c6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,153 +1,237 @@ #![windows_subsystem = "windows"] -mod imhex; -mod tray; -mod utils; -mod updater; +pub mod imhex; +pub mod tray; +pub mod utils; +pub mod updater; use discord_rich_presence::{activity::{Activity, Timestamps}, DiscordIpc, DiscordIpcClient}; -use log::error; -use std::error::Error; -use std::fs::{self, File, OpenOptions}; +use log::{error, info}; +use std::fs::{self, OpenOptions}; use std::io::Write; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::thread; +use std::time::Duration; use chrono::Local; use tokio::runtime::Runtime; +const CLIENT_ID: &str = "1060827018196955177"; +const UPDATE_INTERVAL: Duration = Duration::from_millis(100); +const RECONNECT_DELAY: Duration = Duration::from_secs(5); + +#[derive(Debug)] +pub enum AppError { + Discord(String), + Filesystem(std::io::Error), + Configuration(String), +} + +impl std::fmt::Display for AppError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AppError::Discord(msg) => write!(f, "Discord error: {}", msg), + AppError::Filesystem(err) => write!(f, "Filesystem error: {}", err), + AppError::Configuration(msg) => write!(f, "Configuration error: {}", msg), + } + } +} + +impl std::error::Error for AppError {} + +impl From for AppError { + fn from(err: std::io::Error) -> Self { + AppError::Filesystem(err) + } +} + +#[derive(Debug, Eq, PartialEq)] +struct ActivityState { + state: String, + details: String, +} + +struct Config { + client_id: String, + log_dir: PathBuf, + update_interval: Duration, +} + +impl Config { + fn new() -> Result { + let home_dir = std::env::var("USERPROFILE") + .map_err(|_| AppError::Configuration("Failed to get user profile".to_string()))?; + + Ok(Config { + client_id: CLIENT_ID.to_string(), + log_dir: PathBuf::from(home_dir).join(".discord-imhex"), + update_interval: UPDATE_INTERVAL, + }) + } +} + pub struct DiscordClient { client: DiscordIpcClient, + last_activity: Option, } impl DiscordClient { - pub fn new(client_id: &str) -> Result> { - let mut client = DiscordIpcClient::new(client_id)?; - client.connect()?; - Ok(Self { client }) + pub fn new(client_id: &str) -> Result { + let mut client = DiscordIpcClient::new(client_id) + .map_err(|e| AppError::Discord(e.to_string()))?; + client.connect() + .map_err(|e| AppError::Discord(e.to_string()))?; + + Ok(Self { + client, + last_activity: None, + }) } - pub fn set_activity(&mut self, state: String, details: String, timestamps: Timestamps) -> Result<(), Box> { - let activity = Activity::new().state(&state).details(&details).timestamps(timestamps); - self.client.set_activity(activity) + pub fn update_activity(&mut self, state: String, details: String, timestamps: Timestamps) -> Result<(), AppError> { + let new_activity = ActivityState { + state: state.clone(), + details: details.clone(), + }; + + if Some(&new_activity) != self.last_activity.as_ref() { + let activity = Activity::new() + .state(&state) + .details(&details) + .timestamps(timestamps); + + self.client.set_activity(activity) + .map_err(|e| AppError::Discord(e.to_string()))?; + self.last_activity = Some(new_activity); + } + Ok(()) } - pub fn clear_activity(&mut self) -> Result<(), Box> { + pub fn clear_activity(&mut self) -> Result<(), AppError> { self.client.clear_activity() + .map_err(|e| AppError::Discord(e.to_string()))?; + self.last_activity = None; + Ok(()) } } -pub fn create_timestamps(start_time: i64) -> Timestamps { - Timestamps::new().start(start_time) +struct AppState { + running: Arc, + start_time: Option, + imhex_running: bool, } -fn setup_logging() -> Result<(), Box> { - let home_dir = std::env::var("USERPROFILE")?; - let log_dir = Path::new(&home_dir).join(".discord-imhex"); +impl AppState { + fn new() -> Self { + Self { + running: Arc::new(AtomicBool::new(true)), + start_time: None, + imhex_running: false, + } + } +} +fn setup_logging(log_dir: &Path) -> Result<(), AppError> { if !log_dir.exists() { - fs::create_dir(&log_dir)?; + fs::create_dir(log_dir)?; } let log_file_path = log_dir.join("error.log"); - File::create(&log_file_path)?; - let mut file = OpenOptions::new() .create(true) .append(true) .open(&log_file_path)?; let timestamp = Local::now(); - writeln!(file, "[{}] Log file successfully created in {:?}", timestamp.format("%Y-%m-%d %H:%M:%S"), log_dir)?; + writeln!(file, "[{}] Log file successfully created in {:?}", + timestamp.format("%Y-%m-%d %H:%M:%S"), log_dir)?; Ok(()) } -fn main() -> Result<(), Box> { - setup_logging()?; - let client_id = "1060827018196955177"; - let running = Arc::new(AtomicBool::new(true)); - let running_clone = Arc::clone(&running); - - let _tray_icon = tray::create_tray_icon(&running_clone).map_err(|e| { - error!("Failed to create tray icon: {}", e); - e - })?; - - let rt = Runtime::new()?; - rt.spawn(updater::start_updater()); - - let mut start_time: Option = None; - let mut imhex_was_running = false; - - while running.load(Ordering::SeqCst) { - match DiscordClient::new(client_id) { - Ok(mut client) => { - while running.load(Ordering::SeqCst) { - if imhex::is_imhex_running() { - handle_imhex_running(&mut client, &mut start_time, &mut imhex_was_running)?; - } else { - handle_imhex_not_running(&mut client, &mut imhex_was_running, &mut start_time)?; - } - thread::sleep(std::time::Duration::from_millis(100)); - } - client.clear_activity().map_err(|e| { - error!("Failed to clear activity: {}", e); - e - })?; - } - Err(e) => { - error!("Failed to connect to Discord client: {}", e); - thread::sleep(std::time::Duration::from_secs(5)); - } - } - } - - Ok(()) +fn create_timestamps(start_time: i64) -> Timestamps { + Timestamps::new().start(start_time) } -fn handle_imhex_running(client: &mut DiscordClient, start_time: &mut Option, imhex_was_running: &mut bool) -> Result<(), Box> { +fn handle_imhex_running(client: &mut DiscordClient, state: &mut AppState) -> Result<(), AppError> { let current_time = utils::get_current_timestamp(); - if !*imhex_was_running { - *start_time = Some(current_time); - *imhex_was_running = true; + if !state.imhex_running { + state.start_time = Some(current_time); + state.imhex_running = true; } if let Some(current_opened_file) = imhex::check_if_imhex_window_exists() { - let timestamps = create_timestamps(start_time.unwrap()); + let timestamps = create_timestamps(state.start_time.unwrap()); let selected_bytes = imhex::get_selected_bytes().unwrap_or_else(|| "None".to_string()); - let state = format!("Bytes: [{}]", selected_bytes); + let activity_state = format!("Bytes: [{}]", selected_bytes); let details = if current_opened_file == "ImHex" { "Idle".to_string() } else { format!("Analyzing: [{}]", current_opened_file) }; - client.set_activity(state, details, timestamps).map_err(|e| { - error!("Failed to set activity: {}", e); - e - })?; + client.update_activity(activity_state, details, timestamps)?; } else { - client.set_activity("".to_string(), "Idle".to_string(), Timestamps::default()).map_err(|e| { - error!("Failed to set activity: {}", e); - e - })?; + client.update_activity("".to_string(), "Idle".to_string(), Timestamps::default())?; + } + + Ok(()) +} + +fn handle_imhex_not_running(client: &mut DiscordClient, state: &mut AppState) -> Result<(), AppError> { + if state.imhex_running { + state.imhex_running = false; + state.start_time = None; + client.clear_activity()?; } + Ok(()) +} +fn run_discord_loop(client: &mut DiscordClient, state: &mut AppState, config: &Config) -> Result<(), AppError> { + while state.running.load(Ordering::SeqCst) { + if imhex::is_imhex_running() { + handle_imhex_running(client, state)?; + } else { + handle_imhex_not_running(client, state)?; + } + thread::sleep(config.update_interval); + } + client.clear_activity()?; Ok(()) } -fn handle_imhex_not_running(client: &mut DiscordClient, imhex_was_running: &mut bool, start_time: &mut Option) -> Result<(), Box> { - if *imhex_was_running { - *imhex_was_running = false; - *start_time = None; - client.clear_activity().map_err(|e| { - error!("Failed to clear activity: {}", e); - e - })?; +fn main() -> Result<(), AppError> { + let config = Config::new()?; + setup_logging(&config.log_dir)?; + + let mut state = AppState::new(); + let running_clone = Arc::clone(&state.running); + + let _tray_icon = tray::create_tray_icon(&running_clone) + .map_err(|e| AppError::Configuration(e.to_string()))?; + + let rt = Runtime::new() + .map_err(|e| AppError::Configuration(e.to_string()))?; + rt.spawn(updater::start_updater()); + + info!("Application started successfully"); + + while state.running.load(Ordering::SeqCst) { + match DiscordClient::new(&config.client_id) { + Ok(mut client) => { + if let Err(e) = run_discord_loop(&mut client, &mut state, &config) { + error!("Error in Discord loop: {}", e); + } + } + Err(e) => { + error!("Failed to connect to Discord client: {}", e); + thread::sleep(RECONNECT_DELAY); + } + } } + info!("Application shutting down"); Ok(()) } \ No newline at end of file diff --git a/src/tray.rs b/src/tray.rs index d3a7271..85eb5b6 100644 --- a/src/tray.rs +++ b/src/tray.rs @@ -1,97 +1,26 @@ use std::env; use std::error::Error; -use std::fs::{File, OpenOptions}; +use std::fs::File; use std::io::Write; -use std::sync::{atomic::{AtomicBool, Ordering}, Arc, Mutex}; +use std::sync::{Arc, Mutex, atomic::{AtomicBool, Ordering}}; use std::thread; -use chrono::Local; use systray::{Application, Error as SystrayError}; -// FIXME add update search for releases tab on github pub const VERSION: &str = env!("CARGO_PKG_VERSION"); pub const ICON: &[u8] = include_bytes!("data/icon.ico"); -pub(crate) fn log_error(message: &str) { - let log_file_path = ".discord-imhex/error.log"; - let mut file = OpenOptions::new() - .create(true) - .append(true) - .open(log_file_path) - .unwrap(); - writeln!(file, "{}", message).unwrap(); -} - pub fn create_tray_icon(running: &Arc) -> Result>, Box> { - let running = Arc::clone(running); - let app = Arc::new(Mutex::new(Application::new().map_err(|e| { - log_error(&format!("Failed to create application: {} at {}", e, Local::now().format("%Y-%m-%d %H:%M:%S"))); - e - })?)); - - let mut temp_icon_path = env::temp_dir(); - temp_icon_path.push("icon.ico"); - { - let mut temp_icon_file = File::create(&temp_icon_path).map_err(|e| { - log_error(&format!("Failed to create temp icon file: {} at {}", e, Local::now().format("%Y-%m-%d %H:%M:%S"))); - e - })?; - temp_icon_file.write_all(ICON).map_err(|e| { - log_error(&format!("Failed to write to temp icon file: {} at {}", e, Local::now().format("%Y-%m-%d %H:%M:%S"))); - e - })?; - } + let app = Arc::new(Mutex::new(Application::new()?)); + let temp_icon_path = create_temp_icon_file()?; { let app = app.lock().unwrap(); - app.set_icon_from_file(temp_icon_path.to_str().unwrap()).map_err(|e| { - log_error(&format!("Failed to set icon from file: {} at {}", e, Local::now().format("%Y-%m-%d %H:%M:%S"))); - e - })?; - app.set_tooltip(&format!("discord-imhex v{}", VERSION)).map_err(|e| { - log_error(&format!("Failed to set tooltip: {} at {}", e, Local::now().format("%Y-%m-%d %H:%M:%S"))); - e - })?; + app.set_icon_from_file(temp_icon_path.to_str().unwrap())?; + app.set_tooltip(&format!("discord-imhex v{}", VERSION))?; } - { - let app = Arc::clone(&app); - app.lock().unwrap().add_menu_item(&format!("discord-imhex v{}", VERSION), |_| -> Result<(), SystrayError> { - let _ = open::that("https://github.com/0xSolanaceae/discord-imhex"); - Ok(()) - }).map_err(|e| { - log_error(&format!("Failed to add menu item: {} at {}", e, Local::now().format("%Y-%m-%d %H:%M:%S"))); - e - })?; - app.lock().unwrap().add_menu_separator().map_err(|e| { - log_error(&format!("Failed to add menu separator: {} at {}", e, Local::now().format("%Y-%m-%d %H:%M:%S"))); - e - })?; - } - - { - let app = Arc::clone(&app); - app.lock().unwrap().add_menu_item("View Logs", |_| -> Result<(), SystrayError> { - let user_profile = env::var("USERPROFILE").unwrap(); - let folder_path = format!("{}\\.discord-imhex", user_profile); - let _ = open::that(folder_path); - Ok(()) - }).map_err(|e| { - log_error(&format!("Failed to add open folder menu item: {} at {}", e, Local::now().format("%Y-%m-%d %H:%M:%S"))); - e - })?; - } - - { - let app = Arc::clone(&app); - app.lock().unwrap().add_menu_item("Exit", move |_| -> Result<(), SystrayError> { - running.store(false, Ordering::SeqCst); - std::process::exit(0); - }).map_err(|e| { - log_error(&format!("Failed to add exit menu item: {} at {}", e, Local::now().format("%Y-%m-%d %H:%M:%S"))); - e - })?; - } + add_menu_items(&app, running)?; let app_clone = Arc::clone(&app); thread::spawn(move || { @@ -99,4 +28,45 @@ pub fn create_tray_icon(running: &Arc) -> Result Result> { + let mut temp_icon_path = env::temp_dir(); + temp_icon_path.push("icon.ico"); + + let mut file = File::create(&temp_icon_path)?; + file.write_all(ICON)?; + + Ok(temp_icon_path) +} + +fn add_menu_items(app: &Arc>, running: &Arc) -> Result<(), Box> { + let app = Arc::clone(app); + + app.lock().unwrap().add_menu_item(&format!("discord-imhex v{}", VERSION), |_| -> Result<(), SystrayError> { + let _ = open::that("https://github.com/0xSolanaceae/discord-imhex"); + Ok(()) + })?; + + app.lock().unwrap().add_menu_separator()?; + + app.lock().unwrap().add_menu_item("View Logs", |_| -> Result<(), SystrayError> { + if let Ok(folder_path) = get_logs_folder() { + let _ = open::that(folder_path); + } + Ok(()) + })?; + + let running_clone = Arc::clone(running); + app.lock().unwrap().add_menu_item("Exit", move |_| -> Result<(), SystrayError> { + running_clone.store(false, Ordering::SeqCst); + std::process::exit(0); + })?; + + Ok(()) +} + +fn get_logs_folder() -> Result { + let user_profile = env::var("USERPROFILE")?; + Ok(format!("{}\\.discord-imhex", user_profile)) +} diff --git a/src/updater.rs b/src/updater.rs index 5d6daa8..1f3026c 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -9,6 +9,7 @@ use semver::Version; use dirs::home_dir; use std::env; use chrono::Local; +use lazy_static::lazy_static; #[derive(Deserialize)] struct Release { @@ -21,10 +22,13 @@ struct Asset { browser_download_url: String, } -pub async fn check_for_updates() -> Result<(), Error> { +lazy_static! { + static ref CLIENT: reqwest::Client = reqwest::Client::new(); +} + +pub async fn check_for_updates() -> Result<(), Box> { let url = "https://api.github.com/repos/0xSolanaceae/discord-imhex/releases/latest"; - let client = reqwest::Client::new(); - let response = client + let response = CLIENT .get(url) .header("User-Agent", "discord-imhex") .send() @@ -35,8 +39,8 @@ pub async fn check_for_updates() -> Result<(), Error> { let latest_version = response.tag_name.trim_start_matches('v'); let current_version = env!("CARGO_PKG_VERSION").trim_start_matches('v'); - let latest_version = Version::parse(latest_version).expect("Invalid latest version format"); - let current_version = Version::parse(current_version).expect("Invalid current version format"); + let latest_version = Version::parse(latest_version)?; + let current_version = Version::parse(current_version)?; if latest_version > current_version { log_message(&format!("Update available: v{} -> v{}", current_version, latest_version)); @@ -78,9 +82,11 @@ pub async fn start_updater() { let mut interval = interval(Duration::from_secs(60 * 60 * 4)); loop { interval.tick().await; - if let Err(e) = check_for_updates().await { - log_message(&format!("Failed to check for updates: {}", e)); - } + tokio::spawn(async { + if let Err(e) = check_for_updates().await { + log_message(&format!("Failed to check for updates: {}", e)); + } + }); } }