diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0bb7087..a14cfe6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2439,6 +2439,7 @@ dependencies = [ "bstr", "either", "erased-serde", + "futures-util", "libc", "mlua-sys", "num-traits", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1b5d0df..0faaaf2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -45,7 +45,7 @@ gif = "0.14.1" raw-window-handle = "0.6" enigo = { version = "0.6.1", features = ["wayland"] } lazy_static = "1.5.0" -mlua = { version = "0.11", default-features = false, features = ["lua54", "vendored", "serde"] } +mlua = { version = "0.11", default-features = false, features = ["lua54", "vendored", "serde", "async"] } [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-global-shortcut = "2" tauri-plugin-positioner = "2" diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 8e238b5..e041147 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -6,7 +6,6 @@ pub mod dolls; pub mod friends; pub mod interaction; pub mod sprite; -pub mod user_status; use crate::lock_r; use crate::state::{init_app_data_scoped, AppDataRefreshScope, FDOLL}; diff --git a/src-tauri/src/commands/user_status.rs b/src-tauri/src/commands/user_status.rs deleted file mode 100644 index 5015a5d..0000000 --- a/src-tauri/src/commands/user_status.rs +++ /dev/null @@ -1,10 +0,0 @@ -use crate::services::active_app::AppMetadata; -use crate::services::ws::UserStatusPayload; -use crate::services::ws::report_user_status; - -#[tauri::command] -pub async fn send_user_status_cmd(app_metadata: AppMetadata, state: String) -> Result<(), String> { - let payload = UserStatusPayload { app_metadata, state }; - report_user_status(payload).await; - Ok(()) -} diff --git a/src-tauri/src/init/mod.rs b/src-tauri/src/init/mod.rs index e40e128..329d2a8 100644 --- a/src-tauri/src/init/mod.rs +++ b/src-tauri/src/init/mod.rs @@ -4,7 +4,6 @@ use crate::{ tracing::init_logging, }, services::{ - active_app::init_foreground_app_change_listener, auth::get_session_token, cursor::init_cursor_tracking, presence_modules::init_modules, @@ -27,7 +26,6 @@ pub async fn launch_app() { init_system_tray(); init_cursor_tracking().await; init_modules(); - init_foreground_app_change_listener(); if let Err(err) = validate_server_health().await { handle_disasterous_failure(Some(err.to_string())).await; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cf85a1e..2554605 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -15,7 +15,6 @@ use commands::friends::{ }; use commands::interaction::send_interaction_cmd; use commands::sprite::recolor_gif_base64; -use commands::user_status::send_user_status_cmd; use tauri::async_runtime; static APP_HANDLE: std::sync::OnceLock> = std::sync::OnceLock::new(); @@ -87,7 +86,6 @@ pub fn run() { reset_password, logout_and_restart, send_interaction_cmd, - send_user_status_cmd ]) .setup(|app| { APP_HANDLE diff --git a/src-tauri/src/services/active_app/icon_cache.rs b/src-tauri/src/services/active_app/icon_cache.rs deleted file mode 100644 index b97e512..0000000 --- a/src-tauri/src/services/active_app/icon_cache.rs +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index ecea310..0000000 --- a/src-tauri/src/services/active_app/macos.rs +++ /dev/null @@ -1,268 +0,0 @@ -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::{info, 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; - } - - // Render icon at exact pixel dimensions to avoid multi-representation bloat. - // NSImage.setSize only changes display size, not pixel data. On Retina Macs, - // TIFFRepresentation can include 2x/3x representations (512x512+), producing - // oversized base64 strings that crash WebSocket payloads. - // Instead: create a new bitmap at exactly ICON_SIZE x ICON_SIZE pixels and - // draw the icon into it, then export that single representation as PNG. - let size = ICON_SIZE as u64; - let bitmap: *mut AnyObject = msg_send![class!(NSBitmapImageRep), alloc]; - let bitmap: *mut AnyObject = msg_send![ - bitmap, - initWithBitmapDataPlanes: std::ptr::null::<*mut u8>(), - pixelsWide: size, - pixelsHigh: size, - bitsPerSample: 8u64, - samplesPerPixel: 4u64, - hasAlpha: true, - isPlanar: false, - colorSpaceName: &*NSString::from_str("NSCalibratedRGBColorSpace"), - bytesPerRow: 0u64, - bitsPerPixel: 0u64 - ]; - if bitmap.is_null() { - warn!("Failed to create bitmap rep for {}", path_str); - return None; - } - - // Save/restore graphics context to draw icon into our bitmap - let _: () = msg_send![class!(NSGraphicsContext), saveGraphicsState]; - let ctx: *mut AnyObject = msg_send![ - class!(NSGraphicsContext), - graphicsContextWithBitmapImageRep: bitmap - ]; - if ctx.is_null() { - let _: () = msg_send![class!(NSGraphicsContext), restoreGraphicsState]; - warn!("Failed to create graphics context for {}", path_str); - return None; - } - let _: () = msg_send![class!(NSGraphicsContext), setCurrentContext: ctx]; - - // Draw the icon into the bitmap at exact pixel size - let draw_rect = objc2_foundation::NSRect::new( - objc2_foundation::NSPoint::new(0.0, 0.0), - objc2_foundation::NSSize::new(ICON_SIZE, ICON_SIZE), - ); - let from_rect = objc2_foundation::NSRect::new( - objc2_foundation::NSPoint::new(0.0, 0.0), - objc2_foundation::NSSize::new(0.0, 0.0), // zero = use full source - ); - // 2 = NSCompositingOperationSourceOver - let _: () = msg_send![ - icon, - drawInRect: draw_rect, - fromRect: from_rect, - operation: 2u64, - fraction: 1.0f64 - ]; - - let _: () = msg_send![class!(NSGraphicsContext), restoreGraphicsState]; - - // Export bitmap as PNG (4 = NSBitmapImageFileTypePNG) - let png_data: *mut AnyObject = msg_send![ - bitmap, - representationUsingType: 4u64, - properties: std::ptr::null::() - ]; - if png_data.is_null() { - warn!("Failed to export icon as PNG for {}", 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 data_slice = slice::from_raw_parts(bytes, len); - let encoded = STANDARD.encode(data_slice); - - info!( - "App icon captured: {}, PNG bytes: {}, base64 size: {} bytes", - path_str, - len, - encoded.len() - ); - - // Cap icon size to prevent oversized WebSocket payloads - if encoded.len() > super::types::MAX_ICON_B64_SIZE { - warn!( - "Icon for {} exceeds size limit ({} > {} bytes), skipping", - path_str, - encoded.len(), - super::types::MAX_ICON_B64_SIZE - ); - return None; - } - - 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 deleted file mode 100644 index e64a675..0000000 --- a/src-tauri/src/services/active_app/mod.rs +++ /dev/null @@ -1,88 +0,0 @@ -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_metadata: AppMetadata| { - { - let guard = lock_r!(FDOLL); - if guard - .network - .clients - .as_ref() - .map(|c| c.is_ws_initialized) - .unwrap_or(false) - { - // Check if app metadata has valid data - let has_valid_name = app_metadata - .localized - .as_ref() - .or(app_metadata.unlocalized.as_ref()) - .map(|s| !s.trim().is_empty()) - .unwrap_or(false); - - if has_valid_name { - let payload = crate::services::ws::UserStatusPayload { - app_metadata: app_metadata.clone(), - 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_metadata) { - 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 deleted file mode 100644 index ea8fe1c..0000000 --- a/src-tauri/src/services/active_app/types.rs +++ /dev/null @@ -1,21 +0,0 @@ -use serde::{Deserialize, Serialize}; -use ts_rs::TS; - -pub const ICON_SIZE: u32 = 64; -pub const ICON_CACHE_LIMIT: usize = 50; - -/// Maximum base64-encoded icon size in bytes (~50KB). -/// A 64x64 RGBA PNG should be well under this. Anything larger -/// indicates multi-representation or uncompressed data that -/// could crash WebSocket payloads. -pub const MAX_ICON_B64_SIZE: usize = 50_000; - -/// 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 deleted file mode 100644 index 9939dd4..0000000 --- a/src-tauri/src/services/active_app/windows.rs +++ /dev/null @@ -1,459 +0,0 @@ -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::{info, 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, 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 { - 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); - - info!( - "App icon captured: {}, PNG bytes: {}, base64 size: {} bytes", - exe_path, - png_buffer.len(), - encoded.len() - ); - - if encoded.len() > super::types::MAX_ICON_B64_SIZE { - warn!( - "Icon for {} exceeds size limit ({} > {} bytes), skipping", - exe_path, - encoded.len(), - super::types::MAX_ICON_B64_SIZE - ); - return None; - } - - put_cached_icon(exe_path, encoded.clone()); - - Some(encoded) - } -} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 6a471c3..1ff97fa 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -2,7 +2,6 @@ use tauri::Manager; use crate::get_app_handle; -pub mod active_app; pub mod app_menu; pub mod auth; pub mod client_config_manager; diff --git a/src-tauri/src/services/presence_modules/runtime.rs b/src-tauri/src/services/presence_modules/runtime.rs index 69df429..0732e20 100644 --- a/src-tauri/src/services/presence_modules/runtime.rs +++ b/src-tauri/src/services/presence_modules/runtime.rs @@ -1,6 +1,10 @@ use mlua::{Lua, LuaSerdeExt, UserData, UserDataMethods, Value}; use std::{path::Path, thread, time::Duration}; -use tracing::{error, info}; +use tokio::runtime::Runtime; +use tracing::{error, info, warn}; + +use crate::services::ws::user_status::{report_user_status, UserStatusPayload}; +use crate::services::ws::{ws_emit_soft, WS_EVENT}; use super::models::PresenceStatus; @@ -18,19 +22,34 @@ impl UserData for Engine { }); methods.add_method("update_status", |lua, _, value: Value| { let status: PresenceStatus = lua.from_value(value)?; - dbg!(status); + let rt = Runtime::new().unwrap(); + rt.block_on(update_status(status)); + Ok(()) + }); + methods.add_async_method("update_status_async", |lua, _, value: Value| async move { + let status: PresenceStatus = lua.from_value(value)?; + update_status_async(status).await; Ok(()) }); } } -fn load_script(path: &Path) -> Result { - std::fs::read_to_string(path) +async fn update_status(status: PresenceStatus) { + let user_status = UserStatusPayload { + presence_status: status, + state: String::from("idle"), + }; + report_user_status(user_status).await; } -fn setup_engine_globals(lua: &Lua) -> Result<(), mlua::Error> { - let globals = lua.globals(); - globals.set("engine", Engine) +async fn update_status_async(status: PresenceStatus) { + let payload = UserStatusPayload { + presence_status: status, + state: String::from("idle"), + }; + if let Err(e) = ws_emit_soft(WS_EVENT::CLIENT_REPORT_USER_STATUS, payload).await { + warn!("User status report failed: {}", e); + } } pub fn spawn_lua_runtime(script: &str) -> thread::JoinHandle<()> { @@ -38,8 +57,9 @@ pub fn spawn_lua_runtime(script: &str) -> thread::JoinHandle<()> { thread::spawn(move || { let lua = Lua::new(); + let globals = lua.globals(); - if let Err(e) = setup_engine_globals(&lua) { + if let Err(e) = globals.set("engine", Engine) { error!("Failed to set engine global: {}", e); return; } @@ -52,6 +72,6 @@ pub fn spawn_lua_runtime(script: &str) -> thread::JoinHandle<()> { } pub fn spawn_lua_runtime_from_path(path: &Path) -> Result, std::io::Error> { - let script = load_script(path)?; + let script = std::fs::read_to_string(path)?; Ok(spawn_lua_runtime(&script)) } diff --git a/src-tauri/src/services/ws/emitter.rs b/src-tauri/src/services/ws/emitter.rs index 2e5b994..484b4e3 100644 --- a/src-tauri/src/services/ws/emitter.rs +++ b/src-tauri/src/services/ws/emitter.rs @@ -28,11 +28,11 @@ async fn do_emit( let (client_opt, is_initialized) = get_ws_state(); let Some(client) = client_opt else { - return Ok(()); // Client not available, silent skip + return Ok(()); }; if !is_initialized { - return Ok(()); // Not initialized yet, silent skip + return Ok(()); } let payload_value = serde_json::to_value(&payload) @@ -62,7 +62,10 @@ async fn handle_soft_emit_failure(err_msg: &str) { }; if should_reinit { - warn!("WebSocket emit failed {} times, reinitializing connection", MAX_FAILURES); + warn!( + "WebSocket emit failed {} times, reinitializing connection", + MAX_FAILURES + ); let _ = crate::services::ws::client::clear_ws_client().await; crate::services::ws::client::establish_websocket_connection().await; } else { diff --git a/src-tauri/src/services/ws/mod.rs b/src-tauri/src/services/ws/mod.rs index f299475..7540195 100644 --- a/src-tauri/src/services/ws/mod.rs +++ b/src-tauri/src/services/ws/mod.rs @@ -21,12 +21,12 @@ mod handlers; mod interaction; mod refresh; mod types; -mod user_status; +pub mod user_status; mod utils; pub mod client; // Re-export public API pub use cursor::report_cursor_data; +pub use emitter::ws_emit_soft; pub use types::WS_EVENT; -pub use user_status::{report_user_status, UserStatusPayload}; diff --git a/src-tauri/src/services/ws/user_status.rs b/src-tauri/src/services/ws/user_status.rs index 2cd08f9..4044c81 100644 --- a/src-tauri/src/services/ws/user_status.rs +++ b/src-tauri/src/services/ws/user_status.rs @@ -1,11 +1,11 @@ use once_cell::sync::Lazy; use serde::Serialize; +use tauri::async_runtime::{self, JoinHandle}; use tokio::sync::Mutex; -use tokio::task::JoinHandle; use tokio::time::Duration; use tracing::warn; -use crate::services::active_app::AppMetadata; +use crate::services::presence_modules::models::PresenceStatus; use super::{emitter, types::WS_EVENT}; @@ -13,18 +13,17 @@ use super::{emitter, types::WS_EVENT}; #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct UserStatusPayload { - pub app_metadata: AppMetadata, + pub presence_status: PresenceStatus, pub state: String, } +pub static USER_STATUS_CHANGED: &str = "user-status-changed"; + /// Debouncer for user status reports static USER_STATUS_REPORT_DEBOUNCE: Lazy>>> = Lazy::new(|| Mutex::new(None)); /// Report user status to WebSocket server with debouncing -/// -/// Uses soft emit to avoid triggering disaster recovery on failure, -/// since user status is non-critical telemetry. pub async fn report_user_status(status: UserStatusPayload) { let mut debouncer = USER_STATUS_REPORT_DEBOUNCE.lock().await; @@ -33,12 +32,16 @@ pub async fn report_user_status(status: UserStatusPayload) { handle.abort(); } + emitter::emit_to_frontend(USER_STATUS_CHANGED, &status); + // Schedule new report after 500ms - let handle = tokio::spawn(async move { + let handle = async_runtime::spawn(async move { tokio::time::sleep(Duration::from_millis(500)).await; - if let Err(e) = emitter::ws_emit_soft(WS_EVENT::CLIENT_REPORT_USER_STATUS, status).await { + if let Err(e) = + emitter::ws_emit_soft(WS_EVENT::CLIENT_REPORT_USER_STATUS, status.clone()).await + { warn!("User status report failed: {}", e); - } + }; }); *debouncer = Some(handle); diff --git a/src/events/user-status.ts b/src/events/user-status.ts index 8d916ea..6f071b7 100644 --- a/src/events/user-status.ts +++ b/src/events/user-status.ts @@ -1,13 +1,13 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { writable } from "svelte/store"; -import type { AppMetadata } from "../types/bindings/AppMetadata"; +import type { PresenceStatus } from "../types/bindings/PresenceStatus"; -export type FriendUserStatus = { - appMetadata: AppMetadata; +export type UserStatus = { + presenceStatus: PresenceStatus; state: "idle" | "resting"; }; -export const friendsUserStatuses = writable>({}); +export const friendsUserStatuses = writable>({}); let unlistenStatus: UnlistenFn | null = null; let unlistenFriendDisconnected: UnlistenFn | null = null; @@ -29,52 +29,53 @@ export async function initUserStatusListeners() { } const userId = payload?.userId as string | undefined; - const status = payload?.status as FriendUserStatus | undefined; + const status = payload?.status as UserStatus | undefined; if (!userId || !status) return; - if (!status.appMetadata) return; - + if (!status.presenceStatus) return; + // Validate that appMetadata has at least one valid name - const hasValidName = - (typeof status.appMetadata.localized === "string" && status.appMetadata.localized.trim() !== "") || - (typeof status.appMetadata.unlocalized === "string" && status.appMetadata.unlocalized.trim() !== ""); + const hasValidName = + (typeof status.presenceStatus.title === "string" && + status.presenceStatus.title.trim() !== "") || + (typeof status.presenceStatus.subtitle === "string" && + status.presenceStatus.subtitle.trim() !== ""); if (!hasValidName) return; - + if (status.state !== "idle" && status.state !== "resting") return; friendsUserStatuses.update((current) => ({ ...current, [userId]: { - appMetadata: status.appMetadata, + presenceStatus: status.presenceStatus, state: status.state, }, })); }); - unlistenFriendDisconnected = await listen<[{ userId: string }] | { userId: string } | string>( - "friend-disconnected", - (event) => { - let payload = event.payload as any; - if (typeof payload === "string") { - try { - payload = JSON.parse(payload); - } catch (error) { - console.error("Failed to parse friend-disconnected payload", error); - return; - } + unlistenFriendDisconnected = await listen< + [{ userId: string }] | { userId: string } | string + >("friend-disconnected", (event) => { + let payload = event.payload as any; + if (typeof payload === "string") { + try { + payload = JSON.parse(payload); + } catch (error) { + console.error("Failed to parse friend-disconnected payload", error); + return; } + } - const data = Array.isArray(payload) ? payload[0] : payload; - const userId = data?.userId as string | undefined; - if (!userId) return; + const data = Array.isArray(payload) ? payload[0] : payload; + const userId = data?.userId as string | undefined; + if (!userId) return; - friendsUserStatuses.update((current) => { - const next = { ...current }; - delete next[userId]; - return next; - }); - }, - ); + friendsUserStatuses.update((current) => { + const next = { ...current }; + delete next[userId]; + return next; + }); + }); isListening = true; } catch (error) { diff --git a/src/routes/scene/+page.svelte b/src/routes/scene/+page.svelte index 993ea96..55df7ab 100644 --- a/src/routes/scene/+page.svelte +++ b/src/routes/scene/+page.svelte @@ -6,12 +6,15 @@ } from "../../events/cursor"; import { appData } from "../../events/app-data"; import { sceneInteractive } from "../../events/scene-interactive"; - import { friendsUserStatuses } from "../../events/user-status"; + import { + friendsUserStatuses, + type UserStatus, + } from "../../events/user-status"; import { invoke } from "@tauri-apps/api/core"; import DesktopPet from "./components/DesktopPet.svelte"; import { listen } from "@tauri-apps/api/event"; import { onMount } from "svelte"; - import type { AppMetadata } from "../../types/bindings/AppMetadata"; + import type { PresenceStatus } from "../../types/bindings/PresenceStatus"; import type { DollDto } from "../../types/bindings/DollDto"; let innerWidth = $state(0); @@ -43,11 +46,12 @@ return $appData?.dolls?.find((d) => d.id === user.activeDollId); } - let appMetadata: AppMetadata | null = $state(null); + let presenceStatus: PresenceStatus | null = $state(null); onMount(() => { - const unlisten = listen("active-app-changed", (event) => { - appMetadata = event.payload; + const unlisten = listen("user-status-changed", (event) => { + console.log("event received"); + presenceStatus = event.payload.presenceStatus; }); return () => { @@ -89,14 +93,14 @@ - {#if appMetadata?.appIconB64} + {#if presenceStatus?.graphicsB64} Active app icon {/if} - {appMetadata?.localized} + {presenceStatus?.title} {#if Object.keys($friendsCursorPositions).length > 0} @@ -115,15 +119,15 @@ {#if status} {status.state} in - {#if status.appMetadata.appIconB64} + {#if status.presenceStatus.graphicsB64} Friend's active app icon {/if} - {status.appMetadata.localized || - status.appMetadata.unlocalized} + {status.presenceStatus.title || + status.presenceStatus.subtitle} {/if} @@ -163,8 +167,8 @@ username: $appData.user.username, activeDoll: getUserDoll() ?? null, }} - userStatus={appMetadata - ? { appMetadata: appMetadata, state: "idle" } + userStatus={presenceStatus + ? { presenceStatus: presenceStatus, state: "idle" } : undefined} doll={getUserDoll()} isInteractive={false} diff --git a/src/routes/scene/components/DesktopPet.svelte b/src/routes/scene/components/DesktopPet.svelte index 02e6de5..0a010a5 100644 --- a/src/routes/scene/components/DesktopPet.svelte +++ b/src/routes/scene/components/DesktopPet.svelte @@ -12,14 +12,14 @@ import PetMenu from "./PetMenu.svelte"; import type { DollDto } from "../../../types/bindings/DollDto"; import type { UserBasicDto } from "../../../types/bindings/UserBasicDto"; - import type { AppMetadata } from "../../../types/bindings/AppMetadata"; - import type { FriendUserStatus } from "../../../events/user-status"; + import type { PresenceStatus } from "../../../types/bindings/PresenceStatus"; + import type { UserStatus } from "../../../events/user-status"; export let id = ""; export let targetX = 0; export let targetY = 0; export let user: UserBasicDto; - export let userStatus: FriendUserStatus | undefined = undefined; + export let userStatus: UserStatus | undefined = undefined; export let doll: DollDto | undefined = undefined; export let isInteractive = false; @@ -160,9 +160,9 @@ {/if} {#if userStatus}
- {#if userStatus.appMetadata.appIconB64} + {#if userStatus.presenceStatus.graphicsB64} Friend's active app icon diff --git a/src/routes/scene/components/PetMenu.svelte b/src/routes/scene/components/PetMenu.svelte index c589a27..82060cb 100644 --- a/src/routes/scene/components/PetMenu.svelte +++ b/src/routes/scene/components/PetMenu.svelte @@ -3,11 +3,11 @@ import { type DollDto } from "../../../types/bindings/DollDto"; import type { UserBasicDto } from "../../../types/bindings/UserBasicDto"; import type { SendInteractionDto } from "../../../types/bindings/SendInteractionDto"; - import type { FriendUserStatus } from "../../../events/user-status"; + import type { UserStatus } from "../../../events/user-status"; export let doll: DollDto; export let user: UserBasicDto; - export let userStatus: FriendUserStatus | undefined = undefined; + export let userStatus: UserStatus | undefined = undefined; export let receivedMessage: string | undefined = undefined; let showMessageInput = false; @@ -49,15 +49,15 @@
{#if userStatus}
- {#if userStatus.appMetadata.appIconB64} + {#if userStatus.presenceStatus.graphicsB64} Friend's active app icon {/if}

- {userStatus.appMetadata.localized} + {userStatus.presenceStatus.title}

{/if} diff --git a/src/types/bindings/AppMetadata.ts b/src/types/bindings/AppMetadata.ts deleted file mode 100644 index 24aa4f1..0000000 --- a/src/types/bindings/AppMetadata.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Metadata for the currently active application, including localized and unlocalized names, and an optional base64-encoded icon. - */ -export type AppMetadata = { localized: string | null, unlocalized: string | null, appIconB64: string | null, }; diff --git a/src/types/bindings/UpdateUserDto.ts b/src/types/bindings/PresenceStatus.ts similarity index 50% rename from src/types/bindings/UpdateUserDto.ts rename to src/types/bindings/PresenceStatus.ts index 6eea6a4..16483d9 100644 --- a/src/types/bindings/UpdateUserDto.ts +++ b/src/types/bindings/PresenceStatus.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type UpdateUserDto = Record; +export type PresenceStatus = { title: string | null, subtitle: string | null, graphicsB64: string | null, };