diff --git a/src-tauri/src/services/active_app.rs b/src-tauri/src/services/active_app.rs deleted file mode 100644 index 8f17176..0000000 --- a/src-tauri/src/services/active_app.rs +++ /dev/null @@ -1,796 +0,0 @@ -use std::fmt::Debug; -use ts_rs::TS; - -use serde::{Deserialize, Serialize}; -use tauri::Emitter; -use tracing::error; - -use crate::get_app_handle; -use crate::{lock_r, state::FDOLL}; - -const ICON_SIZE: u32 = 64; -const ICON_CACHE_LIMIT: usize = 50; - -/// Metadata for the currently active application, including localized and unlocalized names, and an optional base64-encoded icon. -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -pub struct AppMetadata { - pub localized: Option, - pub unlocalized: Option, - pub app_icon_b64: Option, -} - -/// Module for caching application icons as base64-encoded strings to improve performance. -/// Uses an LRU-style cache with a fixed capacity. -mod icon_cache { - use lazy_static::lazy_static; - use std::collections::VecDeque; - use std::sync::{Arc, Mutex}; - - lazy_static! { - static ref CACHE: Mutex, Arc)>> = Mutex::new(VecDeque::new()); - } - - /// Retrieves a cached icon by path, moving it to the front of the cache if found. - pub fn get(path: &str) -> Option { - let mut cache = CACHE.lock().unwrap(); - if let Some(pos) = cache.iter().position(|(p, _)| p.as_ref() == path) { - let (_, value) = cache.remove(pos).expect("position exists"); - cache.push_front((Arc::from(path), value.clone())); - Some(value.as_ref().to_string()) - } else { - None - } - } - - /// Stores an icon in the cache, evicting the oldest entry if the cache is full. - pub fn put(path: &str, value: String) { - let mut cache = CACHE.lock().unwrap(); - let path_arc = Arc::from(path); - let value_arc = Arc::from(value.as_str()); - if let Some(pos) = cache.iter().position(|(p, _)| p.as_ref() == path) { - cache.remove(pos); - } - cache.push_front((path_arc, value_arc)); - if cache.len() > super::ICON_CACHE_LIMIT { - cache.pop_back(); - } - } -} - -/// Listens for changes in the active (foreground) application and calls the provided callback with metadata. -/// The implementation varies by platform: macOS uses NSWorkspace notifications, Windows uses WinEventHook. -pub fn listen_for_active_app_changes(callback: F) -where - F: Fn(AppMetadata) + Send + 'static, -{ - listen_impl(callback) -} - -#[cfg(target_os = "macos")] -fn listen_impl(callback: F) -where - F: Fn(AppMetadata) + Send + 'static, -{ - macos::listen_for_active_app_changes(callback); -} - -#[cfg(target_os = "windows")] -fn listen_impl(callback: F) -where - F: Fn(AppMetadata) + Send + 'static, -{ - windows_impl::listen_for_active_app_changes(callback); -} - -#[cfg(not(any(target_os = "macos", target_os = "windows")))] -fn listen_impl(_callback: F) -where - F: Fn(AppMetadata) + Send + 'static, -{ - // no-op on unsupported platforms -} - -#[cfg(target_os = "macos")] -mod macos { - use super::AppMetadata; - use base64::engine::general_purpose::STANDARD; - use base64::Engine; - use lazy_static::lazy_static; - use objc2::runtime::{AnyClass, AnyObject, Sel}; - use objc2::{class, msg_send, sel}; - use objc2_foundation::NSString; - use std::ffi::CStr; - use std::sync::{Mutex, Once}; - - #[allow(unused_imports)] // for framework linking, not referenced in code - use objc2_app_kit::NSWorkspace; - - lazy_static! { - static ref CALLBACK: Mutex>> = - Mutex::new(None); - } - static INIT_OBSERVER: Once = Once::new(); - - pub fn listen_for_active_app_changes(callback: F) - where - F: Fn(AppMetadata) + Send + 'static, - { - INIT_OBSERVER.call_once(|| { - register_objc_observer_class(); - }); - *CALLBACK.lock().unwrap() = Some(Box::new(callback)); - - unsafe { - let cls = AnyClass::get(CStr::from_bytes_with_nul(b"RustActiveAppObserver\0").unwrap()) - .unwrap(); - let observer: *mut AnyObject = msg_send![cls, new]; - - let ws: *mut AnyObject = msg_send![class!(NSWorkspace), sharedWorkspace]; - let nc: *mut AnyObject = msg_send![ws, notificationCenter]; - let notif_name = NSString::from_str("NSWorkspaceDidActivateApplicationNotification"); - let _: () = msg_send![ - nc, - addObserver: observer, - selector: sel!(appActivated:), - name: &*notif_name, - object: ws - ]; - } - } - - fn register_objc_observer_class() { - use objc2::runtime::ClassBuilder; - - let cname = CStr::from_bytes_with_nul(b"RustActiveAppObserver\0").unwrap(); - let super_cls = class!(NSObject); - let mut builder = ClassBuilder::new(&cname, super_cls).unwrap(); - unsafe { - builder.add_method( - sel!(appActivated:), - app_activated as extern "C" fn(*mut AnyObject, Sel, *mut AnyObject), - ); - } - builder.register(); - } - - extern "C" fn app_activated(_self: *mut AnyObject, _cmd: Sel, _notif: *mut AnyObject) { - let info = get_active_app_metadata_macos(); - if let Some(cb) = CALLBACK.lock().unwrap().as_ref() { - cb(info); - } - } - - const ICON_SIZE: f64 = super::ICON_SIZE as f64; - - fn get_cached_icon(path: &str) -> Option { - super::icon_cache::get(path) - } - - fn put_cached_icon(path: &str, value: String) { - super::icon_cache::put(path, value); - } - - /// Retrieves metadata for the currently active application on macOS, including names and icon. - pub fn get_active_app_metadata_macos() -> AppMetadata { - use tracing::warn; - - let ws: *mut AnyObject = unsafe { msg_send![class!(NSWorkspace), sharedWorkspace] }; - let front_app: *mut AnyObject = unsafe { msg_send![ws, frontmostApplication] }; - if front_app.is_null() { - warn!("No frontmost application found"); - return AppMetadata { - localized: None, - unlocalized: None, - app_icon_b64: None, - }; - } - - let name: *mut NSString = unsafe { msg_send![front_app, localizedName] }; - let localized = if name.is_null() { - warn!("Localized name is null for frontmost application"); - None - } else { - Some(unsafe { - CStr::from_ptr((*name).UTF8String()) - .to_string_lossy() - .into_owned() - }) - }; - - let exe_url: *mut AnyObject = unsafe { msg_send![front_app, executableURL] }; - let unlocalized = if exe_url.is_null() { - warn!("Executable URL is null for frontmost application"); - None - } else { - let exe_name: *mut NSString = unsafe { msg_send![exe_url, lastPathComponent] }; - if exe_name.is_null() { - warn!("Executable name is null"); - None - } else { - Some(unsafe { - CStr::from_ptr((*exe_name).UTF8String()) - .to_string_lossy() - .into_owned() - }) - } - }; - - let app_icon_b64 = get_active_app_icon_b64(front_app); - - AppMetadata { - localized, - unlocalized, - app_icon_b64, - } - } - - fn get_active_app_icon_b64(front_app: *mut AnyObject) -> Option { - use std::slice; - use tracing::warn; - - unsafe { - let bundle_url: *mut AnyObject = msg_send![front_app, bundleURL]; - if bundle_url.is_null() { - warn!("Failed to fetch icon: bundleURL null"); - return None; - } - - let path: *mut NSString = msg_send![bundle_url, path]; - if path.is_null() { - warn!("Failed to fetch icon: path null"); - return None; - } - - let path_str = CStr::from_ptr((*path).UTF8String()) - .to_string_lossy() - .into_owned(); - - if let Some(cached) = get_cached_icon(&path_str) { - return Some(cached); - } - - let ws: *mut AnyObject = msg_send![class!(NSWorkspace), sharedWorkspace]; - let icon: *mut AnyObject = msg_send![ws, iconForFile: path]; - if icon.is_null() { - warn!( - "Failed to fetch icon for {}: iconForFile returned null", - path_str - ); - return None; - } - - let _: () = - msg_send![icon, setSize: objc2_foundation::NSSize::new(ICON_SIZE, ICON_SIZE)]; - - let tiff: *mut AnyObject = msg_send![icon, TIFFRepresentation]; - if tiff.is_null() { - warn!( - "Failed to fetch icon for {}: TIFFRepresentation null", - path_str - ); - return None; - } - - let rep: *mut AnyObject = msg_send![class!(NSBitmapImageRep), imageRepWithData: tiff]; - if rep.is_null() { - warn!( - "Failed to fetch icon for {}: imageRepWithData null", - path_str - ); - return None; - } - - // 4 = NSBitmapImageFileTypePNG - let png_data: *mut AnyObject = msg_send![ - rep, - representationUsingType: 4u64, - properties: std::ptr::null::() - ]; - if png_data.is_null() { - warn!("Failed to fetch icon for {}: PNG data null", path_str); - return None; - } - - let bytes: *const u8 = msg_send![png_data, bytes]; - let len: usize = msg_send![png_data, length]; - if bytes.is_null() || len == 0 { - warn!("Failed to fetch icon for {}: empty PNG data", path_str); - return None; - } - - let slice = slice::from_raw_parts(bytes, len); - let encoded = STANDARD.encode(slice); - put_cached_icon(&path_str, encoded.clone()); - - Some(encoded) - } - } -} - -#[cfg(target_os = "windows")] -mod windows_impl { - use super::AppMetadata; - use std::ffi::OsString; - use std::iter; - use std::os::windows::ffi::OsStringExt; - use std::path::Path; - use std::ptr; - use windows::core::PCWSTR; - use windows::Win32::Foundation::HWND; - use windows::Win32::Globalization::GetUserDefaultLangID; - use windows::Win32::System::ProcessStatus::GetModuleFileNameExW; - use windows::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION}; - use windows::Win32::UI::Accessibility::{SetWinEventHook, UnhookWinEvent, HWINEVENTHOOK}; - use windows::Win32::UI::WindowsAndMessaging::{ - DispatchMessageW, GetForegroundWindow, GetMessageW, GetWindowTextW, - GetWindowThreadProcessId, EVENT_SYSTEM_FOREGROUND, MSG, WINEVENT_OUTOFCONTEXT, - }; - - pub fn listen_for_active_app_changes(callback: F) - where - F: Fn(AppMetadata) + Send + 'static, - { - // Run the hook on a background thread so we don't block the caller. - std::thread::spawn(move || unsafe { - let hook = SetWinEventHook( - EVENT_SYSTEM_FOREGROUND, - EVENT_SYSTEM_FOREGROUND, - None, - Some(win_event_proc::), - 0, - 0, - WINEVENT_OUTOFCONTEXT, - ); - - if hook.is_invalid() { - eprintln!("Failed to set event hook"); - return; - } - - // store callback in TLS for this thread - CALLBACK.with(|cb| cb.replace(Some(Box::new(callback)))); - - let mut msg = MSG::default(); - while GetMessageW(&mut msg, None, 0, 0).as_bool() { - DispatchMessageW(&msg); - } - - let _ = UnhookWinEvent(hook); - }); - } - - // RefCell is safe here because the callback is stored and accessed only within the dedicated event loop thread. - // No concurrent access occurs since WinEventHook runs on a single background thread. - thread_local! { - static CALLBACK: std::cell::RefCell>> = - std::cell::RefCell::new(None); - } - - unsafe extern "system" fn win_event_proc( - _h_win_event_hook: HWINEVENTHOOK, - event: u32, - hwnd: HWND, - _id_object: i32, - _id_child: i32, - _id_event_thread: u32, - _dw_ms_event_time: u32, - ) { - if event == EVENT_SYSTEM_FOREGROUND { - let names = get_active_app_metadata_windows(Some(hwnd)); - CALLBACK.with(|cb| { - if let Some(ref cb) = *cb.borrow() { - cb(names); - } - }); - } - } - - /// Retrieves metadata for the active application on Windows, optionally using a provided window handle. - pub fn get_active_app_metadata_windows(hwnd_override: Option) -> AppMetadata { - use tracing::warn; - - unsafe { - let hwnd = hwnd_override.unwrap_or_else(|| GetForegroundWindow()); - if hwnd.0 == std::ptr::null_mut() { - warn!("No foreground window found"); - return AppMetadata { - localized: None, - unlocalized: None, - app_icon_b64: None, - }; - } - let mut pid: u32 = 0; - GetWindowThreadProcessId(hwnd, Some(&mut pid)); - if pid == 0 { - warn!("Failed to get process ID for foreground window"); - return AppMetadata { - localized: None, - unlocalized: None, - app_icon_b64: None, - }; - } - let process_handle = match OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) { - Ok(h) if !h.is_invalid() => h, - _ => { - warn!("Failed to open process {} for querying", pid); - return AppMetadata { - localized: None, - unlocalized: None, - app_icon_b64: None, - }; - } - }; - - let mut buffer: [u16; 1024] = [0; 1024]; - let size = GetModuleFileNameExW(process_handle, None, &mut buffer); - let exe_path = if size > 0 { - OsString::from_wide(&buffer[..size as usize]) - } else { - warn!("Failed to get module file name for process {}", pid); - return AppMetadata { - localized: None, - unlocalized: None, - app_icon_b64: None, - }; - }; - let exe_path_str = exe_path.to_string_lossy(); - let exe_name = Path::new(&*exe_path_str) - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or(""); - let is_uwp = exe_name.eq_ignore_ascii_case("ApplicationFrameHost.exe"); - let (localized, unlocalized) = if is_uwp { - ( - get_window_title(hwnd), - Some("ApplicationFrameHost".to_string()), - ) - } else { - let unlocalized = Path::new(&*exe_path_str) - .file_stem() - .and_then(|s| s.to_str()) - .map(|s| s.to_string()); - let localized = get_file_description(&exe_path_str).or_else(|| unlocalized.clone()); - (localized, unlocalized) - }; - - let app_icon_b64 = get_active_app_icon_b64(&exe_path_str); - - AppMetadata { - localized, - unlocalized, - app_icon_b64, - } - } - } - - fn get_window_title(hwnd: HWND) -> Option { - unsafe { - let mut buffer: [u16; 512] = [0; 512]; - let len = GetWindowTextW(hwnd, &mut buffer); - if len == 0 { - None - } else { - Some( - OsString::from_wide(&buffer[..len as usize]) - .to_string_lossy() - .into_owned(), - ) - } - } - } - - fn get_file_description(exe_path: &str) -> Option { - unsafe { - let wide_path: Vec = exe_path.encode_utf16().chain(iter::once(0)).collect(); - let size = windows::Win32::Storage::FileSystem::GetFileVersionInfoSizeW( - PCWSTR(wide_path.as_ptr()), - None, - ); - if size == 0 { - return None; - } - let mut buffer: Vec = vec![0; size as usize]; - let result = windows::Win32::Storage::FileSystem::GetFileVersionInfoW( - PCWSTR(wide_path.as_ptr()), - 0, - size, - buffer.as_mut_ptr() as *mut _, - ); - if result.is_err() { - return None; - } - let mut translations_ptr: *mut u8 = ptr::null_mut(); - let mut translations_len: u32 = 0; - let translation_query: Vec = "\\VarFileInfo\\Translation" - .encode_utf16() - .chain(iter::once(0)) - .collect(); - let trans_result = windows::Win32::Storage::FileSystem::VerQueryValueW( - buffer.as_ptr() as *const _, - PCWSTR(translation_query.as_ptr()), - &mut translations_ptr as *mut _ as *mut *mut _, - &mut translations_len, - ); - let system_lang_id = GetUserDefaultLangID(); - if trans_result.as_bool() && translations_len >= 4 { - let translations = std::slice::from_raw_parts( - translations_ptr as *const u16, - (translations_len / 2) as usize, - ); - let mut lang_attempts = Vec::new(); - for i in (0..translations.len()).step_by(2) { - if i + 1 < translations.len() { - let lang = translations[i]; - let codepage = translations[i + 1]; - if lang == system_lang_id { - lang_attempts.insert(0, (lang, codepage)); - } else { - lang_attempts.push((lang, codepage)); - } - } - } - for (lang, codepage) in lang_attempts { - let query = format!( - "\\StringFileInfo\\{:04x}{:04x}\\FileDescription", - lang, codepage - ); - let wide_query: Vec = query.encode_utf16().chain(iter::once(0)).collect(); - let mut value_ptr: *mut u8 = ptr::null_mut(); - let mut value_len: u32 = 0; - let query_result = windows::Win32::Storage::FileSystem::VerQueryValueW( - buffer.as_ptr() as *const _, - PCWSTR(wide_query.as_ptr()), - &mut value_ptr as *mut _ as *mut *mut _, - &mut value_len, - ); - if query_result.as_bool() && !value_ptr.is_null() && value_len > 0 { - let wide_str = std::slice::from_raw_parts( - value_ptr as *const u16, - (value_len as usize).saturating_sub(1), - ); - let description = - OsString::from_wide(wide_str).to_string_lossy().into_owned(); - if !description.is_empty() { - return Some(description); - } - } - } - } - let fallback_query: Vec = "\\StringFileInfo\\040904b0\\FileDescription" - .encode_utf16() - .chain(iter::once(0)) - .collect(); - let mut value_ptr: *mut u8 = ptr::null_mut(); - let mut value_len: u32 = 0; - let query_result = windows::Win32::Storage::FileSystem::VerQueryValueW( - buffer.as_ptr() as *const _, - PCWSTR(fallback_query.as_ptr()), - &mut value_ptr as *mut _ as *mut *mut _, - &mut value_len, - ); - if query_result.as_bool() && !value_ptr.is_null() && value_len > 0 { - let wide_str = std::slice::from_raw_parts( - value_ptr as *const u16, - (value_len as usize).saturating_sub(1), - ); - Some(OsString::from_wide(wide_str).to_string_lossy().into_owned()) - } else { - None - } - } - } - - const ICON_SIZE: u32 = 64; - - fn get_cached_icon(path: &str) -> Option { - super::icon_cache::get(path) - } - - fn put_cached_icon(path: &str, value: String) { - super::icon_cache::put(path, value); - } - - /// RAII wrapper for Windows HICON handles to ensure proper cleanup. - struct IconHandle(windows::Win32::UI::WindowsAndMessaging::HICON); - - impl Drop for IconHandle { - fn drop(&mut self) { - unsafe { - windows::Win32::UI::WindowsAndMessaging::DestroyIcon(self.0); - } - } - } - - fn get_active_app_icon_b64(exe_path: &str) -> Option { - use base64::engine::general_purpose::STANDARD; - use base64::Engine; - use tracing::warn; - use windows::Win32::Graphics::Gdi::{ - CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject, GetDIBits, - GetObjectW, SelectObject, BITMAP, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, - }; - use windows::Win32::UI::Shell::{SHGetFileInfoW, SHFILEINFOW, SHGFI_ICON, SHGFI_LARGEICON}; - use windows::Win32::UI::WindowsAndMessaging::{DestroyIcon, GetIconInfo, ICONINFO}; - - // Check cache first - if let Some(cached) = get_cached_icon(exe_path) { - return Some(cached); - } - - unsafe { - let wide_path: Vec = exe_path.encode_utf16().chain(iter::once(0)).collect(); - let mut file_info: SHFILEINFOW = std::mem::zeroed(); - - use windows::Win32::Storage::FileSystem::FILE_FLAGS_AND_ATTRIBUTES; - - let result = SHGetFileInfoW( - PCWSTR(wide_path.as_ptr()), - FILE_FLAGS_AND_ATTRIBUTES(0), - Some(&mut file_info), - std::mem::size_of::() as u32, - SHGFI_ICON | SHGFI_LARGEICON, - ); - - if result == 0 || file_info.hIcon.is_invalid() { - warn!("Failed to get icon for {}", exe_path); - return None; - } - - let icon_handle = IconHandle(file_info.hIcon); - - // Get icon info to extract bitmap - let mut icon_info: ICONINFO = std::mem::zeroed(); - if GetIconInfo(icon_handle.0, &mut icon_info).is_err() { - warn!("Failed to get icon info for {}", exe_path); - return None; - } - - // Get bitmap dimensions - let mut bitmap: BITMAP = std::mem::zeroed(); - if GetObjectW( - icon_info.hbmColor, - std::mem::size_of::() as i32, - Some(&mut bitmap as *mut _ as *mut _), - ) == 0 - { - let _ = DeleteObject(icon_info.hbmColor); - let _ = DeleteObject(icon_info.hbmMask); - warn!("Failed to get bitmap object for {}", exe_path); - return None; - } - - let width = bitmap.bmWidth; - let height = bitmap.bmHeight; - - // Create device contexts - let hdc_screen = windows::Win32::Graphics::Gdi::GetDC(HWND(ptr::null_mut())); - let hdc_mem = CreateCompatibleDC(hdc_screen); - let hbm_new = CreateCompatibleBitmap( - hdc_screen, - super::ICON_SIZE as i32, - super::ICON_SIZE as i32, - ); - let hbm_old = SelectObject(hdc_mem, hbm_new); - - // Draw icon - let _ = windows::Win32::UI::WindowsAndMessaging::DrawIconEx( - hdc_mem, - 0, - 0, - icon_handle.0, - super::ICON_SIZE as i32, - super::ICON_SIZE as i32, - 0, - None, - windows::Win32::UI::WindowsAndMessaging::DI_NORMAL, - ); - - // Prepare BITMAPINFO for GetDIBits - let mut bmi: BITMAPINFO = std::mem::zeroed(); - bmi.bmiHeader.biSize = std::mem::size_of::() as u32; - bmi.bmiHeader.biWidth = super::ICON_SIZE as i32; - bmi.bmiHeader.biHeight = -(super::ICON_SIZE as i32); // Top-down - bmi.bmiHeader.biPlanes = 1; - bmi.bmiHeader.biBitCount = 32; - bmi.bmiHeader.biCompression = BI_RGB.0 as u32; - - // Allocate buffer for pixel data - let buffer_size = (super::ICON_SIZE * super::ICON_SIZE * 4) as usize; - let mut buffer: Vec = vec![0; buffer_size]; - - // Get bitmap bits - let result = GetDIBits( - hdc_mem, - hbm_new, - 0, - super::ICON_SIZE, - Some(buffer.as_mut_ptr() as *mut _), - &mut bmi, - DIB_RGB_COLORS, - ); - - // Clean up - let _ = SelectObject(hdc_mem, hbm_old); - let _ = DeleteObject(hbm_new); - let _ = DeleteDC(hdc_mem); - let _ = windows::Win32::Graphics::Gdi::ReleaseDC(HWND(ptr::null_mut()), hdc_screen); - let _ = DeleteObject(icon_info.hbmColor); - let _ = DeleteObject(icon_info.hbmMask); - - if result == 0 { - warn!("Failed to get bitmap bits for {}", exe_path); - return None; - } - - // Convert BGRA to RGBA - for i in (0..buffer.len()).step_by(4) { - buffer.swap(i, i + 2); // Swap B and R - } - - // Encode as PNG using image crate - let img = match image::RgbaImage::from_raw(ICON_SIZE, ICON_SIZE, buffer) { - Some(img) => img, - None => { - warn!("Failed to create image from buffer for {}", exe_path); - return None; - } - }; - - let mut png_buffer = Vec::new(); - if let Err(e) = img.write_to( - &mut std::io::Cursor::new(&mut png_buffer), - image::ImageFormat::Png, - ) { - warn!("Failed to encode PNG for {}: {}", exe_path, e); - return None; - } - - let encoded = STANDARD.encode(&png_buffer); - put_cached_icon(exe_path, encoded.clone()); - - Some(encoded) - } - } -} - -pub static ACTIVE_APP_CHANGED: &str = "active-app-changed"; - -/// Initializes the foreground app change listener -/// and emits events to the Tauri app on changes. -/// Used for app to emit user foreground app to peers. -pub fn init_foreground_app_change_listener() { - let app_handle = get_app_handle(); - listen_for_active_app_changes(|app_names: AppMetadata| { - { - let guard = lock_r!(FDOLL); - if guard - .network - .clients - .as_ref() - .map(|c| c.is_ws_initialized) - .unwrap_or(false) - { - let active_app_value = app_names - .localized - .as_ref() - .or(app_names.unlocalized.as_ref()) - .unwrap_or(&String::new()) - .clone(); - if !active_app_value.trim().is_empty() { - let payload = crate::services::ws::UserStatusPayload { - active_app: active_app_value, - state: "idle".to_string(), - }; - tauri::async_runtime::spawn(async move { - crate::services::ws::report_user_status(payload).await; - }); - } - } - }; - if let Err(e) = app_handle.emit(ACTIVE_APP_CHANGED, app_names) { - error!("Failed to emit active app changed event: {}", e); - }; - }); -} diff --git a/src-tauri/src/services/active_app/icon_cache.rs b/src-tauri/src/services/active_app/icon_cache.rs new file mode 100644 index 0000000..b97e512 --- /dev/null +++ b/src-tauri/src/services/active_app/icon_cache.rs @@ -0,0 +1,33 @@ +use lazy_static::lazy_static; +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +lazy_static! { + static ref CACHE: Mutex, Arc)>> = Mutex::new(VecDeque::new()); +} + +/// Retrieves a cached icon by path, moving it to the front of the cache if found. +pub fn get(path: &str) -> Option { + let mut cache = CACHE.lock().unwrap(); + if let Some(pos) = cache.iter().position(|(p, _)| p.as_ref() == path) { + let (_, value) = cache.remove(pos).expect("position exists"); + cache.push_front((Arc::from(path), value.clone())); + Some(value.as_ref().to_string()) + } else { + None + } +} + +/// Stores an icon in the cache, evicting the oldest entry if the cache is full. +pub fn put(path: &str, value: String) { + let mut cache = CACHE.lock().unwrap(); + let path_arc = Arc::from(path); + let value_arc = Arc::from(value.as_str()); + if let Some(pos) = cache.iter().position(|(p, _)| p.as_ref() == path) { + cache.remove(pos); + } + cache.push_front((path_arc, value_arc)); + if cache.len() > super::types::ICON_CACHE_LIMIT { + cache.pop_back(); + } +} diff --git a/src-tauri/src/services/active_app/macos.rs b/src-tauri/src/services/active_app/macos.rs new file mode 100644 index 0000000..c1bec82 --- /dev/null +++ b/src-tauri/src/services/active_app/macos.rs @@ -0,0 +1,210 @@ +use super::types::AppMetadata; +use base64::engine::general_purpose::STANDARD; +use base64::Engine; +use lazy_static::lazy_static; +use objc2::runtime::{AnyClass, AnyObject, Sel}; +use objc2::{class, msg_send, sel}; +use objc2_foundation::NSString; +use std::ffi::CStr; +use std::sync::{Mutex, Once}; +use tracing::warn; + +#[allow(unused_imports)] // for framework linking, not referenced in code +use objc2_app_kit::NSWorkspace; + +lazy_static! { + static ref CALLBACK: Mutex>> = + Mutex::new(None); +} +static INIT_OBSERVER: Once = Once::new(); + +pub fn listen_for_active_app_changes(callback: F) +where + F: Fn(AppMetadata) + Send + 'static, +{ + INIT_OBSERVER.call_once(|| { + register_objc_observer_class(); + }); + *CALLBACK.lock().unwrap() = Some(Box::new(callback)); + + unsafe { + let cls = + AnyClass::get(CStr::from_bytes_with_nul(b"RustActiveAppObserver\0").unwrap()).unwrap(); + let observer: *mut AnyObject = msg_send![cls, new]; + + let ws: *mut AnyObject = msg_send![class!(NSWorkspace), sharedWorkspace]; + let nc: *mut AnyObject = msg_send![ws, notificationCenter]; + let notif_name = NSString::from_str("NSWorkspaceDidActivateApplicationNotification"); + let _: () = msg_send![ + nc, + addObserver: observer, + selector: sel!(appActivated:), + name: &*notif_name, + object: ws + ]; + } +} + +fn register_objc_observer_class() { + use objc2::runtime::ClassBuilder; + + let cname = CStr::from_bytes_with_nul(b"RustActiveAppObserver\0").unwrap(); + let super_cls = class!(NSObject); + let mut builder = ClassBuilder::new(&cname, super_cls).unwrap(); + unsafe { + builder.add_method( + sel!(appActivated:), + app_activated as extern "C" fn(*mut AnyObject, Sel, *mut AnyObject), + ); + } + builder.register(); +} + +extern "C" fn app_activated(_self: *mut AnyObject, _cmd: Sel, _notif: *mut AnyObject) { + let info = get_active_app_metadata_macos(); + if let Some(cb) = CALLBACK.lock().unwrap().as_ref() { + cb(info); + } +} + +const ICON_SIZE: f64 = super::types::ICON_SIZE as f64; + +fn get_cached_icon(path: &str) -> Option { + super::icon_cache::get(path) +} + +fn put_cached_icon(path: &str, value: String) { + super::icon_cache::put(path, value); +} + +/// Retrieves metadata for the currently active application on macOS, including names and icon. +pub fn get_active_app_metadata_macos() -> AppMetadata { + let ws: *mut AnyObject = unsafe { msg_send![class!(NSWorkspace), sharedWorkspace] }; + let front_app: *mut AnyObject = unsafe { msg_send![ws, frontmostApplication] }; + if front_app.is_null() { + warn!("No frontmost application found"); + return AppMetadata { + localized: None, + unlocalized: None, + app_icon_b64: None, + }; + } + + let name: *mut NSString = unsafe { msg_send![front_app, localizedName] }; + let localized = if name.is_null() { + warn!("Localized name is null for frontmost application"); + None + } else { + Some(unsafe { + CStr::from_ptr((*name).UTF8String()) + .to_string_lossy() + .into_owned() + }) + }; + + let exe_url: *mut AnyObject = unsafe { msg_send![front_app, executableURL] }; + let unlocalized = if exe_url.is_null() { + warn!("Executable URL is null for frontmost application"); + None + } else { + let exe_name: *mut NSString = unsafe { msg_send![exe_url, lastPathComponent] }; + if exe_name.is_null() { + warn!("Executable name is null"); + None + } else { + Some(unsafe { + CStr::from_ptr((*exe_name).UTF8String()) + .to_string_lossy() + .into_owned() + }) + } + }; + + let app_icon_b64 = get_active_app_icon_b64(front_app); + + AppMetadata { + localized, + unlocalized, + app_icon_b64, + } +} + +fn get_active_app_icon_b64(front_app: *mut AnyObject) -> Option { + use std::slice; + + unsafe { + let bundle_url: *mut AnyObject = msg_send![front_app, bundleURL]; + if bundle_url.is_null() { + warn!("Failed to fetch icon: bundleURL null"); + return None; + } + + let path: *mut NSString = msg_send![bundle_url, path]; + if path.is_null() { + warn!("Failed to fetch icon: path null"); + return None; + } + + let path_str = CStr::from_ptr((*path).UTF8String()) + .to_string_lossy() + .into_owned(); + + if let Some(cached) = get_cached_icon(&path_str) { + return Some(cached); + } + + let ws: *mut AnyObject = msg_send![class!(NSWorkspace), sharedWorkspace]; + let icon: *mut AnyObject = msg_send![ws, iconForFile: path]; + if icon.is_null() { + warn!( + "Failed to fetch icon for {}: iconForFile returned null", + path_str + ); + return None; + } + + let _: () = msg_send![icon, setSize: objc2_foundation::NSSize::new(ICON_SIZE, ICON_SIZE)]; + + let tiff: *mut AnyObject = msg_send![icon, TIFFRepresentation]; + if tiff.is_null() { + warn!( + "Failed to fetch icon for {}: TIFFRepresentation null", + path_str + ); + return None; + } + + let rep: *mut AnyObject = msg_send![class!(NSBitmapImageRep), imageRepWithData: tiff]; + if rep.is_null() { + warn!( + "Failed to fetch icon for {}: imageRepWithData null", + path_str + ); + return None; + } + + // 4 = NSBitmapImageFileTypePNG + let png_data: *mut AnyObject = msg_send![ + rep, + representationUsingType: 4u64, + properties: std::ptr::null::() + ]; + if png_data.is_null() { + warn!("Failed to fetch icon for {}: PNG data null", path_str); + return None; + } + + let bytes: *const u8 = msg_send![png_data, bytes]; + let len: usize = msg_send![png_data, length]; + if bytes.is_null() || len == 0 { + warn!("Failed to fetch icon for {}: empty PNG data", path_str); + return None; + } + + let slice = slice::from_raw_parts(bytes, len); + let encoded = STANDARD.encode(slice); + put_cached_icon(&path_str, encoded.clone()); + + Some(encoded) + } +} diff --git a/src-tauri/src/services/active_app/mod.rs b/src-tauri/src/services/active_app/mod.rs new file mode 100644 index 0000000..9afe9d1 --- /dev/null +++ b/src-tauri/src/services/active_app/mod.rs @@ -0,0 +1,86 @@ +use tauri::Emitter; +use tracing::error; + +use crate::get_app_handle; +use crate::{lock_r, state::FDOLL}; + +pub use types::*; +mod icon_cache; +#[cfg(target_os = "macos")] +mod macos; +mod types; +#[cfg(target_os = "windows")] +mod windows; + +/// Listens for changes in the active (foreground) application and calls the provided callback with metadata. +/// The implementation varies by platform: macOS uses NSWorkspace notifications, Windows uses WinEventHook. +pub fn listen_for_active_app_changes(callback: F) +where + F: Fn(AppMetadata) + Send + 'static, +{ + listen_impl(callback) +} + +#[cfg(target_os = "macos")] +fn listen_impl(callback: F) +where + F: Fn(AppMetadata) + Send + 'static, +{ + macos::listen_for_active_app_changes(callback); +} + +#[cfg(target_os = "windows")] +fn listen_impl(callback: F) +where + F: Fn(AppMetadata) + Send + 'static, +{ + windows::listen_for_active_app_changes(callback); +} + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +fn listen_impl(_callback: F) +where + F: Fn(AppMetadata) + Send + 'static, +{ + // no-op on unsupported platforms +} + +pub static ACTIVE_APP_CHANGED: &str = "active-app-changed"; + +/// Initializes the foreground app change listener +/// and emits events to the Tauri app on changes. +/// Used for app to emit user foreground app to peers. +pub fn init_foreground_app_change_listener() { + let app_handle = get_app_handle(); + listen_for_active_app_changes(|app_names: AppMetadata| { + { + let guard = lock_r!(FDOLL); + if guard + .network + .clients + .as_ref() + .map(|c| c.is_ws_initialized) + .unwrap_or(false) + { + let active_app_value = app_names + .localized + .as_ref() + .or(app_names.unlocalized.as_ref()) + .unwrap_or(&String::new()) + .clone(); + if !active_app_value.trim().is_empty() { + let payload = crate::services::ws::UserStatusPayload { + active_app: active_app_value, + state: "idle".to_string(), + }; + tauri::async_runtime::spawn(async move { + crate::services::ws::report_user_status(payload).await; + }); + } + } + }; + if let Err(e) = app_handle.emit(ACTIVE_APP_CHANGED, app_names) { + error!("Failed to emit active app changed event: {}", e); + }; + }); +} diff --git a/src-tauri/src/services/active_app/types.rs b/src-tauri/src/services/active_app/types.rs new file mode 100644 index 0000000..79d049e --- /dev/null +++ b/src-tauri/src/services/active_app/types.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +pub const ICON_SIZE: u32 = 64; +pub const ICON_CACHE_LIMIT: usize = 50; + +/// Metadata for the currently active application, including localized and unlocalized names, and an optional base64-encoded icon. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct AppMetadata { + pub localized: Option, + pub unlocalized: Option, + pub app_icon_b64: Option, +} diff --git a/src-tauri/src/services/active_app/windows.rs b/src-tauri/src/services/active_app/windows.rs new file mode 100644 index 0000000..09feca3 --- /dev/null +++ b/src-tauri/src/services/active_app/windows.rs @@ -0,0 +1,441 @@ +use super::types::AppMetadata; +use std::ffi::OsString; +use std::iter; +use std::os::windows::ffi::OsStringExt; +use std::path::Path; +use std::ptr; +use tracing::warn; +use windows::core::PCWSTR; +use windows::Win32::Foundation::HWND; +use windows::Win32::Globalization::GetUserDefaultLangID; +use windows::Win32::System::ProcessStatus::GetModuleFileNameExW; +use windows::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION}; +use windows::Win32::UI::Accessibility::{SetWinEventHook, UnhookWinEvent, HWINEVENTHOOK}; +use windows::Win32::UI::WindowsAndMessaging::{ + DispatchMessageW, GetForegroundWindow, GetMessageW, EVENT_SYSTEM_FOREGROUND, MSG, WINEVENT_OUTOFCONTEXT, + GetWindowTextW, GetWindowThreadProcessId, +}; + +pub fn listen_for_active_app_changes(callback: F) +where + F: Fn(AppMetadata) + Send + 'static, +{ + // Run the hook on a background thread so we don't block the caller. + std::thread::spawn(move || unsafe { + let hook = SetWinEventHook( + EVENT_SYSTEM_FOREGROUND, + EVENT_SYSTEM_FOREGROUND, + None, + Some(win_event_proc::), + 0, + 0, + WINEVENT_OUTOFCONTEXT, + ); + + if hook.is_invalid() { + eprintln!("Failed to set event hook"); + return; + } + + // store callback in TLS for this thread + CALLBACK.with(|cb| cb.replace(Some(Box::new(callback)))); + + let mut msg = MSG::default(); + while GetMessageW(&mut msg, None, 0, 0).as_bool() { + DispatchMessageW(&msg); + } + + let _ = UnhookWinEvent(hook); + }); +} + +// RefCell is safe here because the callback is stored and accessed only within the dedicated event loop thread. +// No concurrent access occurs since WinEventHook runs on a single background thread. +thread_local! { + static CALLBACK: std::cell::RefCell>> = + std::cell::RefCell::new(None); +} + +unsafe extern "system" fn win_event_proc( + _h_win_event_hook: HWINEVENTHOOK, + event: u32, + hwnd: HWND, + _id_object: i32, + _id_child: i32, + _id_event_thread: u32, + _dw_ms_event_time: u32, +) { + if event == EVENT_SYSTEM_FOREGROUND { + let names = get_active_app_metadata_windows(Some(hwnd)); + CALLBACK.with(|cb| { + if let Some(ref cb) = *cb.borrow() { + cb(names); + } + }); + } +} + +/// Retrieves metadata for the active application on Windows, optionally using a provided window handle. +pub fn get_active_app_metadata_windows(hwnd_override: Option) -> AppMetadata { + unsafe { + let hwnd = hwnd_override.unwrap_or_else(|| GetForegroundWindow()); + if hwnd.0 == std::ptr::null_mut() { + warn!("No foreground window found"); + return AppMetadata { + localized: None, + unlocalized: None, + app_icon_b64: None, + }; + } + let mut pid: u32 = 0; + GetWindowThreadProcessId(hwnd, Some(&mut pid)); + if pid == 0 { + warn!("Failed to get process ID for foreground window"); + return AppMetadata { + localized: None, + unlocalized: None, + app_icon_b64: None, + }; + } + let process_handle = match OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) { + Ok(h) if !h.is_invalid() => h, + _ => { + warn!("Failed to open process {} for querying", pid); + return AppMetadata { + localized: None, + unlocalized: None, + app_icon_b64: None, + }; + } + }; + + let mut buffer: [u16; 1024] = [0; 1024]; + let size = GetModuleFileNameExW(process_handle, None, &mut buffer); + let exe_path = if size > 0 { + OsString::from_wide(&buffer[..size as usize]) + } else { + warn!("Failed to get module file name for process {}", pid); + return AppMetadata { + localized: None, + unlocalized: None, + app_icon_b64: None, + }; + }; + let exe_path_str = exe_path.to_string_lossy(); + let exe_name = Path::new(&*exe_path_str) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(""); + let is_uwp = exe_name.eq_ignore_ascii_case("ApplicationFrameHost.exe"); + let (localized, unlocalized) = if is_uwp { + ( + get_window_title(hwnd), + Some("ApplicationFrameHost".to_string()), + ) + } else { + let unlocalized = Path::new(&*exe_path_str) + .file_stem() + .and_then(|s| s.to_str()) + .map(|s| s.to_string()); + let localized = get_file_description(&exe_path_str).or_else(|| unlocalized.clone()); + (localized, unlocalized) + }; + + let app_icon_b64 = get_active_app_icon_b64(&exe_path_str); + + AppMetadata { + localized, + unlocalized, + app_icon_b64, + } + } +} + +fn get_window_title(hwnd: HWND) -> Option { + unsafe { + let mut buffer: [u16; 512] = [0; 512]; + let len = GetWindowTextW(hwnd, &mut buffer); + if len == 0 { + None + } else { + Some( + OsString::from_wide(&buffer[..len as usize]) + .to_string_lossy() + .into_owned(), + ) + } + } +} + +fn get_file_description(exe_path: &str) -> Option { + unsafe { + let wide_path: Vec = exe_path.encode_utf16().chain(iter::once(0)).collect(); + let size = windows::Win32::Storage::FileSystem::GetFileVersionInfoSizeW( + PCWSTR(wide_path.as_ptr()), + None, + ); + if size == 0 { + return None; + } + let mut buffer: Vec = vec![0; size as usize]; + let result = windows::Win32::Storage::FileSystem::GetFileVersionInfoW( + PCWSTR(wide_path.as_ptr()), + 0, + size, + buffer.as_mut_ptr() as *mut _, + ); + if result.is_err() { + return None; + } + let mut translations_ptr: *mut u8 = ptr::null_mut(); + let mut translations_len: u32 = 0; + let translation_query: Vec = "\\VarFileInfo\\Translation" + .encode_utf16() + .chain(iter::once(0)) + .collect(); + let trans_result = windows::Win32::Storage::FileSystem::VerQueryValueW( + buffer.as_ptr() as *const _, + PCWSTR(translation_query.as_ptr()), + &mut translations_ptr as *mut _ as *mut *mut _, + &mut translations_len, + ); + let system_lang_id = GetUserDefaultLangID(); + if trans_result.as_bool() && translations_len >= 4 { + let translations = std::slice::from_raw_parts( + translations_ptr as *const u16, + (translations_len / 2) as usize, + ); + let mut lang_attempts = Vec::new(); + for i in (0..translations.len()).step_by(2) { + if i + 1 < translations.len() { + let lang = translations[i]; + let codepage = translations[i + 1]; + if lang == system_lang_id { + lang_attempts.insert(0, (lang, codepage)); + } else { + lang_attempts.push((lang, codepage)); + } + } + } + for (lang, codepage) in lang_attempts { + let query = format!( + "\\StringFileInfo\\{:04x}{:04x}\\FileDescription", + lang, codepage + ); + let wide_query: Vec = query.encode_utf16().chain(iter::once(0)).collect(); + let mut value_ptr: *mut u8 = ptr::null_mut(); + let mut value_len: u32 = 0; + let query_result = windows::Win32::Storage::FileSystem::VerQueryValueW( + buffer.as_ptr() as *const _, + PCWSTR(wide_query.as_ptr()), + &mut value_ptr as *mut _ as *mut *mut _, + &mut value_len, + ); + if query_result.as_bool() && !value_ptr.is_null() && value_len > 0 { + let wide_str = std::slice::from_raw_parts( + value_ptr as *const u16, + (value_len as usize).saturating_sub(1), + ); + let description = OsString::from_wide(wide_str).to_string_lossy().into_owned(); + if !description.is_empty() { + return Some(description); + } + } + } + } + let fallback_query: Vec = "\\StringFileInfo\\040904b0\\FileDescription" + .encode_utf16() + .chain(iter::once(0)) + .collect(); + let mut value_ptr: *mut u8 = ptr::null_mut(); + let mut value_len: u32 = 0; + let query_result = windows::Win32::Storage::FileSystem::VerQueryValueW( + buffer.as_ptr() as *const _, + PCWSTR(fallback_query.as_ptr()), + &mut value_ptr as *mut _ as *mut *mut _, + &mut value_len, + ); + if query_result.as_bool() && !value_ptr.is_null() && value_len > 0 { + let wide_str = std::slice::from_raw_parts( + value_ptr as *const u16, + (value_len as usize).saturating_sub(1), + ); + Some(OsString::from_wide(wide_str).to_string_lossy().into_owned()) + } else { + None + } + } +} + +fn get_cached_icon(path: &str) -> Option { + super::icon_cache::get(path) +} + +fn put_cached_icon(path: &str, value: String) { + super::icon_cache::put(path, value); +} + +/// RAII wrapper for Windows HICON handles to ensure proper cleanup. +struct IconHandle(windows::Win32::UI::WindowsAndMessaging::HICON); + +impl Drop for IconHandle { + fn drop(&mut self) { + unsafe { + windows::Win32::UI::WindowsAndMessaging::DestroyIcon(self.0); + } + } +} + +fn get_active_app_icon_b64(exe_path: &str) -> Option { + use base64::engine::general_purpose::STANDARD; + use base64::Engine; + use windows::Win32::Graphics::Gdi::{ + CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject, GetDIBits, GetObjectW, + SelectObject, BITMAP, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, + }; + use windows::Win32::UI::Shell::{SHGetFileInfoW, SHFILEINFOW, SHGFI_ICON, SHGFI_LARGEICON}; + use windows::Win32::UI::WindowsAndMessaging::{GetIconInfo, ICONINFO}; + + // Check cache first + if let Some(cached) = get_cached_icon(exe_path) { + return Some(cached); + } + + unsafe { + let wide_path: Vec = exe_path.encode_utf16().chain(iter::once(0)).collect(); + let mut file_info: SHFILEINFOW = std::mem::zeroed(); + + use windows::Win32::Storage::FileSystem::FILE_FLAGS_AND_ATTRIBUTES; + + let result = SHGetFileInfoW( + PCWSTR(wide_path.as_ptr()), + FILE_FLAGS_AND_ATTRIBUTES(0), + Some(&mut file_info), + std::mem::size_of::() as u32, + SHGFI_ICON | SHGFI_LARGEICON, + ); + + if result == 0 || file_info.hIcon.is_invalid() { + warn!("Failed to get icon for {}", exe_path); + return None; + } + + let icon_handle = IconHandle(file_info.hIcon); + + // Get icon info to extract bitmap + let mut icon_info: ICONINFO = std::mem::zeroed(); + if GetIconInfo(icon_handle.0, &mut icon_info).is_err() { + warn!("Failed to get icon info for {}", exe_path); + return None; + } + + // Get bitmap dimensions + let mut bitmap: BITMAP = std::mem::zeroed(); + if GetObjectW( + icon_info.hbmColor, + std::mem::size_of::() as i32, + Some(&mut bitmap as *mut _ as *mut _), + ) == 0 + { + let _ = DeleteObject(icon_info.hbmColor); + let _ = DeleteObject(icon_info.hbmMask); + warn!("Failed to get bitmap object for {}", exe_path); + return None; + } + + let width = bitmap.bmWidth; + let height = bitmap.bmHeight; + + // Create device contexts + let hdc_screen = windows::Win32::Graphics::Gdi::GetDC(HWND(ptr::null_mut())); + let hdc_mem = CreateCompatibleDC(hdc_screen); + let hbm_new = CreateCompatibleBitmap( + hdc_screen, + super::types::ICON_SIZE as i32, + super::types::ICON_SIZE as i32, + ); + let hbm_old = SelectObject(hdc_mem, hbm_new); + + // Draw icon + let _ = windows::Win32::UI::WindowsAndMessaging::DrawIconEx( + hdc_mem, + 0, + 0, + icon_handle.0, + super::types::ICON_SIZE as i32, + super::types::ICON_SIZE as i32, + 0, + None, + windows::Win32::UI::WindowsAndMessaging::DI_NORMAL, + ); + + // Prepare BITMAPINFO for GetDIBits + let mut bmi: BITMAPINFO = std::mem::zeroed(); + bmi.bmiHeader.biSize = std::mem::size_of::() as u32; + bmi.bmiHeader.biWidth = super::types::ICON_SIZE as i32; + bmi.bmiHeader.biHeight = -(super::types::ICON_SIZE as i32); // Top-down + bmi.bmiHeader.biPlanes = 1; + bmi.bmiHeader.biBitCount = 32; + bmi.bmiHeader.biCompression = BI_RGB.0 as u32; + + // Allocate buffer for pixel data + let buffer_size = (super::types::ICON_SIZE * super::types::ICON_SIZE * 4) as usize; + let mut buffer: Vec = vec![0; buffer_size]; + + // Get bitmap bits + let result = GetDIBits( + hdc_mem, + hbm_new, + 0, + super::types::ICON_SIZE, + Some(buffer.as_mut_ptr() as *mut _), + &mut bmi, + DIB_RGB_COLORS, + ); + + // Clean up + let _ = SelectObject(hdc_mem, hbm_old); + let _ = DeleteObject(hbm_new); + let _ = DeleteDC(hdc_mem); + let _ = windows::Win32::Graphics::Gdi::ReleaseDC(HWND(ptr::null_mut()), hdc_screen); + let _ = DeleteObject(icon_info.hbmColor); + let _ = DeleteObject(icon_info.hbmMask); + + if result == 0 { + warn!("Failed to get bitmap bits for {}", exe_path); + return None; + } + + // Convert BGRA to RGBA + for i in (0..buffer.len()).step_by(4) { + buffer.swap(i, i + 2); // Swap B and R + } + + // Encode as PNG using image crate + let img = match image::RgbaImage::from_raw( + super::types::ICON_SIZE, + super::types::ICON_SIZE, + buffer, + ) { + Some(img) => img, + None => { + warn!("Failed to create image from buffer for {}", exe_path); + return None; + } + }; + + let mut png_buffer = Vec::new(); + if let Err(e) = img.write_to( + &mut std::io::Cursor::new(&mut png_buffer), + image::ImageFormat::Png, + ) { + warn!("Failed to encode PNG for {}: {}", exe_path, e); + return None; + } + + let encoded = STANDARD.encode(&png_buffer); + put_cached_icon(exe_path, encoded.clone()); + + Some(encoded) + } +}