From 5145ebaf112b4376edbd9b84974e6bd5da400774 Mon Sep 17 00:00:00 2001 From: Wind-Explorer Date: Sun, 18 Jan 2026 23:42:30 +0800 Subject: [PATCH] integration of `active_app` module --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 22 +- src-tauri/src/app.rs | 5 + src-tauri/src/services/active_app.rs | 425 +++++++++++++++++++++++++++ src-tauri/src/services/mod.rs | 1 + src/routes/scene/+page.svelte | 26 +- src/types/bindings/AppMetadata.ts | 3 + 7 files changed, 475 insertions(+), 8 deletions(-) create mode 100644 src-tauri/src/services/active_app.rs create mode 100644 src/types/bindings/AppMetadata.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 09819b9..b854461 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1184,6 +1184,7 @@ dependencies = [ "gif", "image", "keyring", + "lazy_static", "objc2 0.6.3", "objc2-app-kit", "objc2-foundation 0.3.2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 46c75e0..5475021 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -48,6 +48,7 @@ image = {version = "0.25.9", default-features = false, features = ["gif"] } gif = "0.14.1" raw-window-handle = "0.6" enigo = { version = "0.6.1", features = ["wayland"] } +lazy_static = "1.5.0" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-global-shortcut = "2" @@ -55,11 +56,24 @@ tauri-plugin-positioner = "2" [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6.3" -objc2-app-kit = { version = "0.3.2", features = ["NSWindow", "NSWindowScripting"] } -objc2-foundation = "0.3.2" +objc2-app-kit = { version = "0.3.2", features = [ + "NSWindow", + "NSWindowScripting", + "NSWorkspace", + "NSRunningApplication" +] } +objc2-foundation = { version = "0.3.2", features = ["NSNotification", "NSString", "NSURL"] } [target.'cfg(target_os = "windows")'.dependencies] windows = { version = "0.58", features = [ - "Win32_Foundation", - "Win32_UI_Input_KeyboardAndMouse", + "Win32_Foundation", + "Win32_System_ProcessStatus", + "Win32_UI_WindowsAndMessaging", + "Win32_System_Threading", + "Win32_UI_Shell_PropertiesSystem", + "Win32_System_Com", + "Win32_UI_Accessibility", + "Win32_Storage_FileSystem", + "Win32_Globalization", + "Win32_UI_Input_KeyboardAndMouse", ] } diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs index c29e0ea..1ccc0eb 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -7,6 +7,7 @@ use crate::{ lock_w, remotes::health::{HealthError, HealthRemote}, services::{ + active_app::init_active_app_changes_listener, auth::{get_access_token, get_tokens}, health_manager::show_health_manager_with_error, scene::{close_splash_window, open_scene_window, open_splash_window}, @@ -23,6 +24,10 @@ pub async fn start_fdoll() { let mut guard = lock_w!(FDOLL); guard.tray = Some(tray); } + + // Begin listening for foreground app changes + init_active_app_changes_listener(); + if let Err(err) = init_startup_sequence().await { tracing::error!("startup sequence failed: {err}"); show_health_manager_with_error(Some(err.to_string())); diff --git a/src-tauri/src/services/active_app.rs b/src-tauri/src/services/active_app.rs new file mode 100644 index 0000000..e9fb02b --- /dev/null +++ b/src-tauri/src/services/active_app.rs @@ -0,0 +1,425 @@ +use std::fmt::Debug; +use ts_rs::TS; + +use serde::{Deserialize, Serialize}; +use tauri::Emitter; +use tracing::error; + +use crate::get_app_handle; + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct AppMetadata { + pub localized: Option, + pub unlocalized: Option, +} + +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 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 = unsafe { get_active_app_names_macos_internal() }; + if let Some(cb) = CALLBACK.lock().unwrap().as_ref() { + cb(info); + } + } + + pub unsafe fn get_active_app_names_macos_internal() -> 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, + }; + } + + let name: *mut NSString = msg_send![front_app, localizedName]; + let localized = if name.is_null() { + None + } else { + Some(unsafe { + CStr::from_ptr((*name).UTF8String()) + .to_string_lossy() + .into_owned() + }) + }; + + let exe_url: *mut AnyObject = msg_send![front_app, executableURL]; + let unlocalized = if exe_url.is_null() { + None + } else { + let exe_name: *mut NSString = msg_send![exe_url, lastPathComponent]; + if exe_name.is_null() { + None + } else { + Some(unsafe { + CStr::from_ptr((*exe_name).UTF8String()) + .to_string_lossy() + .into_owned() + }) + } + }; + AppMetadata { + localized, + unlocalized, + } + } +} + +#[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); + }); + } + + 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_names_windows(Some(hwnd)); + CALLBACK.with(|cb| { + if let Some(ref cb) = *cb.borrow() { + cb(names); + } + }); + } + } + + pub fn get_active_app_names_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, + }; + } + let mut pid: u32 = 0; + GetWindowThreadProcessId(hwnd, Some(&mut pid)); + if pid == 0 { + return AppMetadata { + localized: None, + unlocalized: None, + }; + } + let process_handle = match OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) { + Ok(h) if !h.is_invalid() => h, + _ => { + return AppMetadata { + localized: None, + unlocalized: 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 { + return AppMetadata { + localized: None, + unlocalized: 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) + }; + AppMetadata { + localized, + unlocalized, + } + } + } + + 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 + } + } + } +} + +pub static ACTIVE_APP_CHANGED: &str = "active-app-changed"; + +pub fn init_active_app_changes_listener() { + let app_handle = get_app_handle(); + listen_for_active_app_changes(|app_names: AppMetadata| { + 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/mod.rs b/src-tauri/src/services/mod.rs index cd65ba1..9535e80 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -1,3 +1,4 @@ +pub mod active_app; pub mod app_menu; pub mod auth; pub mod client_config_manager; diff --git a/src/routes/scene/+page.svelte b/src/routes/scene/+page.svelte index 68aeb80..e94c522 100644 --- a/src/routes/scene/+page.svelte +++ b/src/routes/scene/+page.svelte @@ -10,11 +10,14 @@ 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"; - let innerWidth = 0; - let innerHeight = 0; + let innerWidth = $state(0); + let innerHeight = $state(0); - $: isInteractive = $sceneInteractive; + let isInteractive = $derived($sceneInteractive); function getFriendById(userId: string) { const friend = $appData?.friends?.find((f) => f.friend.id === userId); @@ -29,6 +32,18 @@ const friend = $appData?.friends?.find((f) => f.friend.id === userId); return friend?.friend.activeDoll; } + + let appMetadata: AppMetadata | null = $state(null); + + onMount(() => { + const unlisten = listen("active-app-changed", (event) => { + appMetadata = event.payload; + }); + + return () => { + unlisten.then((u) => u()); + }; + }); @@ -63,11 +78,14 @@ )}) + + {appMetadata?.localized} + + {#if Object.keys($friendsCursorPositions).length > 0}
{#each Object.entries($friendsCursorPositions) as [userId, position]} - {@const dollConfig = getFriendDoll(userId)}
{getFriendById(userId).name}
diff --git a/src/types/bindings/AppMetadata.ts b/src/types/bindings/AppMetadata.ts new file mode 100644 index 0000000..1a640d2 --- /dev/null +++ b/src/types/bindings/AppMetadata.ts @@ -0,0 +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, };