From 7f2e1489eb6a48b29d8da193b2fbb0b28c54d67f Mon Sep 17 00:00:00 2001 From: Wind-Explorer Date: Mon, 27 Jul 2026 19:26:57 +0800 Subject: [PATCH] user focused app --- src-tauri/Cargo.lock | 172 +++++++++--- src-tauri/Cargo.toml | 9 + src-tauri/src/lib.rs | 15 +- src-tauri/src/ufa/icon_cache.rs | 33 +++ src-tauri/src/ufa/macos.rs | 267 +++++++++++++++++++ src-tauri/src/ufa/mod.rs | 54 ++++ src-tauri/src/ufa/types.rs | 19 ++ src-tauri/src/ufa/windows.rs | 459 ++++++++++++++++++++++++++++++++ 8 files changed, 996 insertions(+), 32 deletions(-) create mode 100644 src-tauri/src/ufa/icon_cache.rs create mode 100644 src-tauri/src/ufa/macos.rs create mode 100644 src-tauri/src/ufa/mod.rs create mode 100644 src-tauri/src/ufa/types.rs create mode 100644 src-tauri/src/ufa/windows.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5cef8da..f82fb0e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -318,9 +318,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.3" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "bytemuck" @@ -1316,7 +1316,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" dependencies = [ "heck 0.4.1", - "proc-macro-crate 2.0.2", + "proc-macro-crate 2.0.0", "proc-macro-error", "proc-macro2", "quote", @@ -1863,6 +1863,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -2113,9 +2119,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.0", "block2", + "libc", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", "objc2-foundation", + "objc2-quartz-core", ] [[package]] @@ -2135,6 +2149,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ + "bitflags 2.13.0", "objc2", "objc2-foundation", ] @@ -2195,6 +2210,19 @@ dependencies = [ "objc2-core-graphics", ] +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -2218,6 +2246,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.0", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2562,11 +2591,10 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "2.0.2" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +checksum = "7e8366a6159044a37876a2b9817124296703c586a5c92e2c53751fa06d8d43e8" dependencies = [ - "toml_datetime 0.6.3", "toml_edit 0.20.2", ] @@ -3244,7 +3272,7 @@ dependencies = [ "cfg-expr", "heck 0.5.0", "pkg-config", - "toml 0.8.2", + "toml 0.8.23", "version-compare", ] @@ -3282,7 +3310,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -3353,7 +3381,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -3452,7 +3480,7 @@ dependencies = [ "tauri-plugin", "thiserror 2.0.18", "url", - "windows", + "windows 0.61.3", "zbus", ] @@ -3478,7 +3506,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -3503,7 +3531,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -3704,14 +3732,14 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.2" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned 0.6.9", - "toml_datetime 0.6.3", - "toml_edit 0.20.2", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -3746,9 +3774,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.3" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ "serde", ] @@ -3778,7 +3806,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ "indexmap 2.14.0", - "toml_datetime 0.6.3", + "toml_datetime 0.6.11", "winnow 0.5.40", ] @@ -3787,12 +3815,23 @@ name = "toml_edit" version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap 2.14.0", "serde", "serde_spanned 0.6.9", - "toml_datetime 0.6.3", - "winnow 0.5.40", + "toml_datetime 0.6.11", + "winnow 0.7.15", ] [[package]] @@ -4308,10 +4347,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", ] [[package]] @@ -4332,7 +4371,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.18", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -4382,6 +4421,16 @@ dependencies = [ "windows-version", ] +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -4404,14 +4453,27 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings 0.4.2", @@ -4423,8 +4485,8 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.2.1", "windows-result 0.4.1", "windows-strings 0.5.1", @@ -4441,6 +4503,17 @@ dependencies = [ "windows-threading", ] +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -4452,6 +4525,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -4485,6 +4569,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -4503,6 +4596,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-strings" version = "0.4.2" @@ -4701,6 +4804,9 @@ name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] [[package]] name = "winnow" @@ -4859,7 +4965,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -4869,11 +4975,17 @@ dependencies = [ name = "wyd" version = "0.1.0" dependencies = [ + "base64 0.22.1", + "lazy_static", + "objc2", + "objc2-app-kit", + "objc2-foundation", "serde", "serde_json", "tauri", "tauri-build", "tauri-plugin-opener", + "windows 0.58.0", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9c9c2ee..845f908 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,4 +22,13 @@ tauri = { version = "2", features = [] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +base64 = "0.22.1" +lazy_static = "1.5.0" +[target.'cfg(target_os = "windows")'.dependencies] +windows = "0.58" + +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6.3" +objc2-app-kit = "0.3.2" +objc2-foundation = "0.3.2" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a74da47..8fd4ffd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,8 +1,19 @@ +mod ufa; + +fn launch_app() { + crate::ufa::init(); +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .invoke_handler(tauri::generate_handler![]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + .setup(|_| { + launch_app(); + Ok(()) + }) + .build(tauri::generate_context!()) + .expect("error while running tauri application") + .run(|_, _| ()); } diff --git a/src-tauri/src/ufa/icon_cache.rs b/src-tauri/src/ufa/icon_cache.rs new file mode 100644 index 0000000..b97e512 --- /dev/null +++ b/src-tauri/src/ufa/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/ufa/macos.rs b/src-tauri/src/ufa/macos.rs new file mode 100644 index 0000000..df15160 --- /dev/null +++ b/src-tauri/src/ufa/macos.rs @@ -0,0 +1,267 @@ +use super::types::AppMeta; +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::println; +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(AppMeta) + 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() -> AppMeta { + 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() { + println!("No frontmost application found"); + return AppMeta { + local: None, + unlocal: None, + ico: None, + }; + } + + let name: *mut NSString = unsafe { msg_send![front_app, localizedName] }; + let localized = if name.is_null() { + println!("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() { + println!("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() { + println!("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); + + AppMeta { + local: localized, + unlocal: unlocalized, + ico: 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() { + println!("Failed to fetch icon: bundleURL null"); + return None; + } + + let path: *mut NSString = msg_send![bundle_url, path]; + if path.is_null() { + println!("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() { + println!( + "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() { + println!("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]; + println!("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() { + println!("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 { + println!("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); + + println!( + "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 { + println!( + "Icon for {} exceeds size limit ({} > {} bytes), skipping", + path_str, + encoded.len(), + crate::ufa::types::MAX_ICON_B64_SIZE + ); + return None; + } + + put_cached_icon(&path_str, encoded.clone()); + + Some(encoded) + } +} diff --git a/src-tauri/src/ufa/mod.rs b/src-tauri/src/ufa/mod.rs new file mode 100644 index 0000000..0f73826 --- /dev/null +++ b/src-tauri/src/ufa/mod.rs @@ -0,0 +1,54 @@ +/* +UFA: User Focused App +*/ + +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 init_listener(callback: F) +where + F: Fn(AppMeta) + Send + 'static, +{ + listen_impl(callback) +} + +#[cfg(target_os = "macos")] +fn listen_impl(callback: F) +where + F: Fn(AppMeta) + Send + 'static, +{ + macos::listen_for_active_app_changes(callback); +} + +#[cfg(target_os = "windows")] +fn listen_impl(callback: F) +where + F: Fn(AppMeta) + 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(AppMeta) + Send + 'static, +{ + // no-op on unsupported platforms +} + +/// 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() { + init_listener(|meta: AppMeta| { + // TODO: Emit metadata to the app + println!("{:?}", meta); + }); +} diff --git a/src-tauri/src/ufa/types.rs b/src-tauri/src/ufa/types.rs new file mode 100644 index 0000000..130a8d2 --- /dev/null +++ b/src-tauri/src/ufa/types.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; + +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)] +#[serde(rename_all = "camelCase")] +pub struct AppMeta { + pub local: Option, + pub unlocal: Option, + pub ico: Option, +} diff --git a/src-tauri/src/ufa/windows.rs b/src-tauri/src/ufa/windows.rs new file mode 100644 index 0000000..9939dd4 --- /dev/null +++ b/src-tauri/src/ufa/windows.rs @@ -0,0 +1,459 @@ +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) + } +}