diff --git a/src-tauri/src/services/active_app.rs b/src-tauri/src/services/active_app.rs index e9fb02b..43001e5 100644 --- a/src-tauri/src/services/active_app.rs +++ b/src-tauri/src/services/active_app.rs @@ -13,6 +13,7 @@ use crate::get_app_handle; pub struct AppMetadata { pub localized: Option, pub unlocalized: Option, + pub app_icon_b64: Option, } pub fn listen_for_active_app_changes(callback: F) @@ -49,12 +50,15 @@ where #[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}; + use tracing::info; #[allow(unused_imports)] // for framework linking, not referenced in code use objc2_app_kit::NSWorkspace; @@ -108,19 +112,50 @@ mod macos { } extern "C" fn app_activated(_self: *mut AnyObject, _cmd: Sel, _notif: *mut AnyObject) { - let info = unsafe { get_active_app_names_macos_internal() }; + let info = unsafe { get_active_app_metadata_macos() }; if let Some(cb) = CALLBACK.lock().unwrap().as_ref() { cb(info); } } - pub unsafe fn get_active_app_names_macos_internal() -> AppMetadata { + const ICON_SIZE: f64 = 64.0; + const ICON_CACHE_LIMIT: usize = 50; + + lazy_static! { + static ref ICON_CACHE: Mutex> = + Mutex::new(std::collections::VecDeque::new()); + } + + fn get_cached_icon(path: &str) -> Option { + let mut cache = ICON_CACHE.lock().ok()?; + if let Some(pos) = cache.iter().position(|(p, _)| p == path) { + let (_, value) = cache.remove(pos)?; + cache.push_front((path.to_string(), value.clone())); + return Some(value); + } + None + } + + fn put_cached_icon(path: &str, value: String) { + if let Ok(mut cache) = ICON_CACHE.lock() { + if let Some(pos) = cache.iter().position(|(p, _)| p == path) { + cache.remove(pos); + } + cache.push_front((path.to_string(), value)); + if cache.len() > ICON_CACHE_LIMIT { + cache.pop_back(); + } + } + } + + pub unsafe fn get_active_app_metadata_macos() -> AppMetadata { let ws: *mut AnyObject = msg_send![class!(NSWorkspace), sharedWorkspace]; let front_app: *mut AnyObject = msg_send![ws, frontmostApplication]; if front_app.is_null() { return AppMetadata { localized: None, unlocalized: None, + app_icon_b64: None, }; } @@ -150,9 +185,95 @@ mod macos { }) } }; + + 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) } } } @@ -224,7 +345,7 @@ mod windows_impl { _dw_ms_event_time: u32, ) { if event == EVENT_SYSTEM_FOREGROUND { - let names = get_active_app_names_windows(Some(hwnd)); + let names = get_active_app_metadata_windows(Some(hwnd)); CALLBACK.with(|cb| { if let Some(ref cb) = *cb.borrow() { cb(names); @@ -233,13 +354,14 @@ mod windows_impl { } } - pub fn get_active_app_names_windows(hwnd_override: Option) -> AppMetadata { + 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() { return AppMetadata { localized: None, unlocalized: None, + app_icon_b64: None, }; } let mut pid: u32 = 0; @@ -248,6 +370,7 @@ mod windows_impl { return AppMetadata { localized: None, unlocalized: None, + app_icon_b64: None, }; } let process_handle = match OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) { @@ -256,9 +379,11 @@ mod windows_impl { 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 { @@ -267,6 +392,7 @@ mod windows_impl { return AppMetadata { localized: None, unlocalized: None, + app_icon_b64: None, }; }; let exe_path_str = exe_path.to_string_lossy(); @@ -291,6 +417,7 @@ mod windows_impl { AppMetadata { localized, unlocalized, + app_icon_b64: None, // TODO: Implement icon fetching for Windows } } } diff --git a/src/routes/scene/+page.svelte b/src/routes/scene/+page.svelte index f3f3f01..f3d69da 100644 --- a/src/routes/scene/+page.svelte +++ b/src/routes/scene/+page.svelte @@ -93,7 +93,14 @@ )}) - + + {#if appMetadata?.appIconB64} + Active app icon + {/if} {appMetadata?.localized} diff --git a/src/types/bindings/AppMetadata.ts b/src/types/bindings/AppMetadata.ts index 1a640d2..6453a62 100644 --- a/src/types/bindings/AppMetadata.ts +++ b/src/types/bindings/AppMetadata.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 AppMetadata = { localized: string | null, unlocalized: string | null, }; +export type AppMetadata = { localized: string | null, unlocalized: string | null, appIconB64: string | null, };