integration of active_app module

This commit is contained in:
2026-01-18 23:42:30 +08:00
parent c888dcf252
commit 5145ebaf11
7 changed files with 475 additions and 8 deletions

1
src-tauri/Cargo.lock generated
View File

@@ -1184,6 +1184,7 @@ dependencies = [
"gif",
"image",
"keyring",
"lazy_static",
"objc2 0.6.3",
"objc2-app-kit",
"objc2-foundation 0.3.2",

View File

@@ -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_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",
] }

View File

@@ -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()));

View File

@@ -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<String>,
pub unlocalized: Option<String>,
}
pub fn listen_for_active_app_changes<F>(callback: F)
where
F: Fn(AppMetadata) + Send + 'static,
{
listen_impl(callback)
}
#[cfg(target_os = "macos")]
fn listen_impl<F>(callback: F)
where
F: Fn(AppMetadata) + Send + 'static,
{
macos::listen_for_active_app_changes(callback);
}
#[cfg(target_os = "windows")]
fn listen_impl<F>(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<F>(_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<Option<Box<dyn Fn(AppMetadata) + Send + 'static>>> =
Mutex::new(None);
}
static INIT_OBSERVER: Once = Once::new();
pub fn listen_for_active_app_changes<F>(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<F>(callback: F)
where
F: Fn(AppMetadata) + Send + 'static,
{
// Run the hook on a background thread so we dont block the caller.
std::thread::spawn(move || unsafe {
let hook = SetWinEventHook(
EVENT_SYSTEM_FOREGROUND,
EVENT_SYSTEM_FOREGROUND,
None,
Some(win_event_proc::<F>),
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<Option<Box<dyn Fn(AppMetadata) + Send + 'static>>> =
std::cell::RefCell::new(None);
}
unsafe extern "system" fn win_event_proc<F>(
_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<HWND>) -> 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<String> {
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<String> {
unsafe {
let wide_path: Vec<u16> = 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<u8> = 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<u16> = "\\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<u16> = 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<u16> = "\\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);
}
});
}

View File

@@ -1,3 +1,4 @@
pub mod active_app;
pub mod app_menu;
pub mod auth;
pub mod client_config_manager;

View File

@@ -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<AppMetadata>("active-app-changed", (event) => {
appMetadata = event.payload;
});
return () => {
unlisten.then((u) => u());
};
});
</script>
<svelte:window bind:innerWidth bind:innerHeight />
@@ -63,11 +78,14 @@
)})
</span>
<span class="font-mono text-xs badge py-3">
{appMetadata?.localized}
</span>
{#if Object.keys($friendsCursorPositions).length > 0}
<div class="flex flex-col gap-2">
<div>
{#each Object.entries($friendsCursorPositions) as [userId, position]}
{@const dollConfig = getFriendDoll(userId)}
<div class="badge py-3 text-xs text-left flex flex-row gap-2">
<span class="font-bold">{getFriendById(userId).name}</span>
<div class="flex flex-col font-mono">

View File

@@ -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, };