event broadcasting & nuking foreground app listener
This commit is contained in:
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@@ -2439,6 +2439,7 @@ dependencies = [
|
|||||||
"bstr",
|
"bstr",
|
||||||
"either",
|
"either",
|
||||||
"erased-serde",
|
"erased-serde",
|
||||||
|
"futures-util",
|
||||||
"libc",
|
"libc",
|
||||||
"mlua-sys",
|
"mlua-sys",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ gif = "0.14.1"
|
|||||||
raw-window-handle = "0.6"
|
raw-window-handle = "0.6"
|
||||||
enigo = { version = "0.6.1", features = ["wayland"] }
|
enigo = { version = "0.6.1", features = ["wayland"] }
|
||||||
lazy_static = "1.5.0"
|
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]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-global-shortcut = "2"
|
tauri-plugin-global-shortcut = "2"
|
||||||
tauri-plugin-positioner = "2"
|
tauri-plugin-positioner = "2"
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ pub mod dolls;
|
|||||||
pub mod friends;
|
pub mod friends;
|
||||||
pub mod interaction;
|
pub mod interaction;
|
||||||
pub mod sprite;
|
pub mod sprite;
|
||||||
pub mod user_status;
|
|
||||||
|
|
||||||
use crate::lock_r;
|
use crate::lock_r;
|
||||||
use crate::state::{init_app_data_scoped, AppDataRefreshScope, FDOLL};
|
use crate::state::{init_app_data_scoped, AppDataRefreshScope, FDOLL};
|
||||||
|
|||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ use crate::{
|
|||||||
tracing::init_logging,
|
tracing::init_logging,
|
||||||
},
|
},
|
||||||
services::{
|
services::{
|
||||||
active_app::init_foreground_app_change_listener,
|
|
||||||
auth::get_session_token,
|
auth::get_session_token,
|
||||||
cursor::init_cursor_tracking,
|
cursor::init_cursor_tracking,
|
||||||
presence_modules::init_modules,
|
presence_modules::init_modules,
|
||||||
@@ -27,7 +26,6 @@ pub async fn launch_app() {
|
|||||||
init_system_tray();
|
init_system_tray();
|
||||||
init_cursor_tracking().await;
|
init_cursor_tracking().await;
|
||||||
init_modules();
|
init_modules();
|
||||||
init_foreground_app_change_listener();
|
|
||||||
|
|
||||||
if let Err(err) = validate_server_health().await {
|
if let Err(err) = validate_server_health().await {
|
||||||
handle_disasterous_failure(Some(err.to_string())).await;
|
handle_disasterous_failure(Some(err.to_string())).await;
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ use commands::friends::{
|
|||||||
};
|
};
|
||||||
use commands::interaction::send_interaction_cmd;
|
use commands::interaction::send_interaction_cmd;
|
||||||
use commands::sprite::recolor_gif_base64;
|
use commands::sprite::recolor_gif_base64;
|
||||||
use commands::user_status::send_user_status_cmd;
|
|
||||||
use tauri::async_runtime;
|
use tauri::async_runtime;
|
||||||
|
|
||||||
static APP_HANDLE: std::sync::OnceLock<tauri::AppHandle<tauri::Wry>> = std::sync::OnceLock::new();
|
static APP_HANDLE: std::sync::OnceLock<tauri::AppHandle<tauri::Wry>> = std::sync::OnceLock::new();
|
||||||
@@ -87,7 +86,6 @@ pub fn run() {
|
|||||||
reset_password,
|
reset_password,
|
||||||
logout_and_restart,
|
logout_and_restart,
|
||||||
send_interaction_cmd,
|
send_interaction_cmd,
|
||||||
send_user_status_cmd
|
|
||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
APP_HANDLE
|
APP_HANDLE
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
use lazy_static::lazy_static;
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
lazy_static! {
|
|
||||||
static ref CACHE: Mutex<VecDeque<(Arc<str>, Arc<str>)>> = 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<String> {
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<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 = 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<String> {
|
|
||||||
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<String> {
|
|
||||||
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::<AnyObject>()
|
|
||||||
];
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<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::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
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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<String>,
|
|
||||||
pub unlocalized: Option<String>,
|
|
||||||
pub app_icon_b64: Option<String>,
|
|
||||||
}
|
|
||||||
@@ -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<F>(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::<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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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<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_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<HWND>) -> 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<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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_cached_icon(path: &str) -> Option<String> {
|
|
||||||
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<String> {
|
|
||||||
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<u16> = 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::<SHFILEINFOW>() 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::<BITMAP>() 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::<BITMAPINFOHEADER>() 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<u8> = 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ use tauri::Manager;
|
|||||||
|
|
||||||
use crate::get_app_handle;
|
use crate::get_app_handle;
|
||||||
|
|
||||||
pub mod active_app;
|
|
||||||
pub mod app_menu;
|
pub mod app_menu;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod client_config_manager;
|
pub mod client_config_manager;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
use mlua::{Lua, LuaSerdeExt, UserData, UserDataMethods, Value};
|
use mlua::{Lua, LuaSerdeExt, UserData, UserDataMethods, Value};
|
||||||
use std::{path::Path, thread, time::Duration};
|
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;
|
use super::models::PresenceStatus;
|
||||||
|
|
||||||
@@ -18,19 +22,34 @@ impl UserData for Engine {
|
|||||||
});
|
});
|
||||||
methods.add_method("update_status", |lua, _, value: Value| {
|
methods.add_method("update_status", |lua, _, value: Value| {
|
||||||
let status: PresenceStatus = lua.from_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(())
|
Ok(())
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_script(path: &Path) -> Result<String, std::io::Error> {
|
async fn update_status(status: PresenceStatus) {
|
||||||
std::fs::read_to_string(path)
|
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> {
|
async fn update_status_async(status: PresenceStatus) {
|
||||||
let globals = lua.globals();
|
let payload = UserStatusPayload {
|
||||||
globals.set("engine", Engine)
|
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<()> {
|
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 || {
|
thread::spawn(move || {
|
||||||
let lua = Lua::new();
|
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);
|
error!("Failed to set engine global: {}", e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -52,6 +72,6 @@ pub fn spawn_lua_runtime(script: &str) -> thread::JoinHandle<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn spawn_lua_runtime_from_path(path: &Path) -> Result<thread::JoinHandle<()>, std::io::Error> {
|
pub fn spawn_lua_runtime_from_path(path: &Path) -> Result<thread::JoinHandle<()>, std::io::Error> {
|
||||||
let script = load_script(path)?;
|
let script = std::fs::read_to_string(path)?;
|
||||||
Ok(spawn_lua_runtime(&script))
|
Ok(spawn_lua_runtime(&script))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,11 +28,11 @@ async fn do_emit<T: Serialize + Send + 'static>(
|
|||||||
let (client_opt, is_initialized) = get_ws_state();
|
let (client_opt, is_initialized) = get_ws_state();
|
||||||
|
|
||||||
let Some(client) = client_opt else {
|
let Some(client) = client_opt else {
|
||||||
return Ok(()); // Client not available, silent skip
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
if !is_initialized {
|
if !is_initialized {
|
||||||
return Ok(()); // Not initialized yet, silent skip
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let payload_value = serde_json::to_value(&payload)
|
let payload_value = serde_json::to_value(&payload)
|
||||||
@@ -62,7 +62,10 @@ async fn handle_soft_emit_failure(err_msg: &str) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if should_reinit {
|
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;
|
let _ = crate::services::ws::client::clear_ws_client().await;
|
||||||
crate::services::ws::client::establish_websocket_connection().await;
|
crate::services::ws::client::establish_websocket_connection().await;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ mod handlers;
|
|||||||
mod interaction;
|
mod interaction;
|
||||||
mod refresh;
|
mod refresh;
|
||||||
mod types;
|
mod types;
|
||||||
mod user_status;
|
pub mod user_status;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
pub mod client;
|
pub mod client;
|
||||||
|
|
||||||
// Re-export public API
|
// Re-export public API
|
||||||
pub use cursor::report_cursor_data;
|
pub use cursor::report_cursor_data;
|
||||||
|
pub use emitter::ws_emit_soft;
|
||||||
pub use types::WS_EVENT;
|
pub use types::WS_EVENT;
|
||||||
pub use user_status::{report_user_status, UserStatusPayload};
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
use tauri::async_runtime::{self, JoinHandle};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tokio::task::JoinHandle;
|
|
||||||
use tokio::time::Duration;
|
use tokio::time::Duration;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
use crate::services::active_app::AppMetadata;
|
use crate::services::presence_modules::models::PresenceStatus;
|
||||||
|
|
||||||
use super::{emitter, types::WS_EVENT};
|
use super::{emitter, types::WS_EVENT};
|
||||||
|
|
||||||
@@ -13,18 +13,17 @@ use super::{emitter, types::WS_EVENT};
|
|||||||
#[derive(Clone, Serialize)]
|
#[derive(Clone, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct UserStatusPayload {
|
pub struct UserStatusPayload {
|
||||||
pub app_metadata: AppMetadata,
|
pub presence_status: PresenceStatus,
|
||||||
pub state: String,
|
pub state: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub static USER_STATUS_CHANGED: &str = "user-status-changed";
|
||||||
|
|
||||||
/// Debouncer for user status reports
|
/// Debouncer for user status reports
|
||||||
static USER_STATUS_REPORT_DEBOUNCE: Lazy<Mutex<Option<JoinHandle<()>>>> =
|
static USER_STATUS_REPORT_DEBOUNCE: Lazy<Mutex<Option<JoinHandle<()>>>> =
|
||||||
Lazy::new(|| Mutex::new(None));
|
Lazy::new(|| Mutex::new(None));
|
||||||
|
|
||||||
/// Report user status to WebSocket server with debouncing
|
/// 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) {
|
pub async fn report_user_status(status: UserStatusPayload) {
|
||||||
let mut debouncer = USER_STATUS_REPORT_DEBOUNCE.lock().await;
|
let mut debouncer = USER_STATUS_REPORT_DEBOUNCE.lock().await;
|
||||||
|
|
||||||
@@ -33,12 +32,16 @@ pub async fn report_user_status(status: UserStatusPayload) {
|
|||||||
handle.abort();
|
handle.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emitter::emit_to_frontend(USER_STATUS_CHANGED, &status);
|
||||||
|
|
||||||
// Schedule new report after 500ms
|
// 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;
|
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);
|
warn!("User status report failed: {}", e);
|
||||||
}
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
*debouncer = Some(handle);
|
*debouncer = Some(handle);
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
import { writable } from "svelte/store";
|
import { writable } from "svelte/store";
|
||||||
import type { AppMetadata } from "../types/bindings/AppMetadata";
|
import type { PresenceStatus } from "../types/bindings/PresenceStatus";
|
||||||
|
|
||||||
export type FriendUserStatus = {
|
export type UserStatus = {
|
||||||
appMetadata: AppMetadata;
|
presenceStatus: PresenceStatus;
|
||||||
state: "idle" | "resting";
|
state: "idle" | "resting";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const friendsUserStatuses = writable<Record<string, FriendUserStatus>>({});
|
export const friendsUserStatuses = writable<Record<string, UserStatus>>({});
|
||||||
|
|
||||||
let unlistenStatus: UnlistenFn | null = null;
|
let unlistenStatus: UnlistenFn | null = null;
|
||||||
let unlistenFriendDisconnected: UnlistenFn | null = null;
|
let unlistenFriendDisconnected: UnlistenFn | null = null;
|
||||||
@@ -29,15 +29,17 @@ export async function initUserStatusListeners() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const userId = payload?.userId as string | undefined;
|
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 (!userId || !status) return;
|
||||||
if (!status.appMetadata) return;
|
if (!status.presenceStatus) return;
|
||||||
|
|
||||||
// Validate that appMetadata has at least one valid name
|
// Validate that appMetadata has at least one valid name
|
||||||
const hasValidName =
|
const hasValidName =
|
||||||
(typeof status.appMetadata.localized === "string" && status.appMetadata.localized.trim() !== "") ||
|
(typeof status.presenceStatus.title === "string" &&
|
||||||
(typeof status.appMetadata.unlocalized === "string" && status.appMetadata.unlocalized.trim() !== "");
|
status.presenceStatus.title.trim() !== "") ||
|
||||||
|
(typeof status.presenceStatus.subtitle === "string" &&
|
||||||
|
status.presenceStatus.subtitle.trim() !== "");
|
||||||
if (!hasValidName) return;
|
if (!hasValidName) return;
|
||||||
|
|
||||||
if (status.state !== "idle" && status.state !== "resting") return;
|
if (status.state !== "idle" && status.state !== "resting") return;
|
||||||
@@ -45,36 +47,35 @@ export async function initUserStatusListeners() {
|
|||||||
friendsUserStatuses.update((current) => ({
|
friendsUserStatuses.update((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[userId]: {
|
[userId]: {
|
||||||
appMetadata: status.appMetadata,
|
presenceStatus: status.presenceStatus,
|
||||||
state: status.state,
|
state: status.state,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
unlistenFriendDisconnected = await listen<[{ userId: string }] | { userId: string } | string>(
|
unlistenFriendDisconnected = await listen<
|
||||||
"friend-disconnected",
|
[{ userId: string }] | { userId: string } | string
|
||||||
(event) => {
|
>("friend-disconnected", (event) => {
|
||||||
let payload = event.payload as any;
|
let payload = event.payload as any;
|
||||||
if (typeof payload === "string") {
|
if (typeof payload === "string") {
|
||||||
try {
|
try {
|
||||||
payload = JSON.parse(payload);
|
payload = JSON.parse(payload);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to parse friend-disconnected payload", error);
|
console.error("Failed to parse friend-disconnected payload", error);
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const data = Array.isArray(payload) ? payload[0] : payload;
|
const data = Array.isArray(payload) ? payload[0] : payload;
|
||||||
const userId = data?.userId as string | undefined;
|
const userId = data?.userId as string | undefined;
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
|
|
||||||
friendsUserStatuses.update((current) => {
|
friendsUserStatuses.update((current) => {
|
||||||
const next = { ...current };
|
const next = { ...current };
|
||||||
delete next[userId];
|
delete next[userId];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
isListening = true;
|
isListening = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -6,12 +6,15 @@
|
|||||||
} from "../../events/cursor";
|
} from "../../events/cursor";
|
||||||
import { appData } from "../../events/app-data";
|
import { appData } from "../../events/app-data";
|
||||||
import { sceneInteractive } from "../../events/scene-interactive";
|
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 { invoke } from "@tauri-apps/api/core";
|
||||||
import DesktopPet from "./components/DesktopPet.svelte";
|
import DesktopPet from "./components/DesktopPet.svelte";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { onMount } from "svelte";
|
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";
|
import type { DollDto } from "../../types/bindings/DollDto";
|
||||||
|
|
||||||
let innerWidth = $state(0);
|
let innerWidth = $state(0);
|
||||||
@@ -43,11 +46,12 @@
|
|||||||
return $appData?.dolls?.find((d) => d.id === user.activeDollId);
|
return $appData?.dolls?.find((d) => d.id === user.activeDollId);
|
||||||
}
|
}
|
||||||
|
|
||||||
let appMetadata: AppMetadata | null = $state(null);
|
let presenceStatus: PresenceStatus | null = $state(null);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const unlisten = listen<AppMetadata>("active-app-changed", (event) => {
|
const unlisten = listen<UserStatus>("user-status-changed", (event) => {
|
||||||
appMetadata = event.payload;
|
console.log("event received");
|
||||||
|
presenceStatus = event.payload.presenceStatus;
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -89,14 +93,14 @@
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="font-mono text-xs badge py-3 flex items-center gap-2">
|
<span class="font-mono text-xs badge py-3 flex items-center gap-2">
|
||||||
{#if appMetadata?.appIconB64}
|
{#if presenceStatus?.graphicsB64}
|
||||||
<img
|
<img
|
||||||
src={`data:image/png;base64,${appMetadata.appIconB64}`}
|
src={`data:image/png;base64,${presenceStatus.graphicsB64}`}
|
||||||
alt="Active app icon"
|
alt="Active app icon"
|
||||||
class="size-4"
|
class="size-4"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{appMetadata?.localized}
|
{presenceStatus?.title}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{#if Object.keys($friendsCursorPositions).length > 0}
|
{#if Object.keys($friendsCursorPositions).length > 0}
|
||||||
@@ -115,15 +119,15 @@
|
|||||||
{#if status}
|
{#if status}
|
||||||
<span class="flex items-center gap-1">
|
<span class="flex items-center gap-1">
|
||||||
{status.state} in
|
{status.state} in
|
||||||
{#if status.appMetadata.appIconB64}
|
{#if status.presenceStatus.graphicsB64}
|
||||||
<img
|
<img
|
||||||
src={`data:image/png;base64,${status.appMetadata.appIconB64}`}
|
src={`data:image/png;base64,${status.presenceStatus.graphicsB64}`}
|
||||||
alt="Friend's active app icon"
|
alt="Friend's active app icon"
|
||||||
class="size-4"
|
class="size-4"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{status.appMetadata.localized ||
|
{status.presenceStatus.title ||
|
||||||
status.appMetadata.unlocalized}
|
status.presenceStatus.subtitle}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -163,8 +167,8 @@
|
|||||||
username: $appData.user.username,
|
username: $appData.user.username,
|
||||||
activeDoll: getUserDoll() ?? null,
|
activeDoll: getUserDoll() ?? null,
|
||||||
}}
|
}}
|
||||||
userStatus={appMetadata
|
userStatus={presenceStatus
|
||||||
? { appMetadata: appMetadata, state: "idle" }
|
? { presenceStatus: presenceStatus, state: "idle" }
|
||||||
: undefined}
|
: undefined}
|
||||||
doll={getUserDoll()}
|
doll={getUserDoll()}
|
||||||
isInteractive={false}
|
isInteractive={false}
|
||||||
|
|||||||
@@ -12,14 +12,14 @@
|
|||||||
import PetMenu from "./PetMenu.svelte";
|
import PetMenu from "./PetMenu.svelte";
|
||||||
import type { DollDto } from "../../../types/bindings/DollDto";
|
import type { DollDto } from "../../../types/bindings/DollDto";
|
||||||
import type { UserBasicDto } from "../../../types/bindings/UserBasicDto";
|
import type { UserBasicDto } from "../../../types/bindings/UserBasicDto";
|
||||||
import type { AppMetadata } from "../../../types/bindings/AppMetadata";
|
import type { PresenceStatus } from "../../../types/bindings/PresenceStatus";
|
||||||
import type { FriendUserStatus } from "../../../events/user-status";
|
import type { UserStatus } from "../../../events/user-status";
|
||||||
|
|
||||||
export let id = "";
|
export let id = "";
|
||||||
export let targetX = 0;
|
export let targetX = 0;
|
||||||
export let targetY = 0;
|
export let targetY = 0;
|
||||||
export let user: UserBasicDto;
|
export let user: UserBasicDto;
|
||||||
export let userStatus: FriendUserStatus | undefined = undefined;
|
export let userStatus: UserStatus | undefined = undefined;
|
||||||
export let doll: DollDto | undefined = undefined;
|
export let doll: DollDto | undefined = undefined;
|
||||||
export let isInteractive = false;
|
export let isInteractive = false;
|
||||||
|
|
||||||
@@ -160,9 +160,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if userStatus}
|
{#if userStatus}
|
||||||
<div class="absolute -top-5 left-0 right-0 w-max mx-auto">
|
<div class="absolute -top-5 left-0 right-0 w-max mx-auto">
|
||||||
{#if userStatus.appMetadata.appIconB64}
|
{#if userStatus.presenceStatus.graphicsB64}
|
||||||
<img
|
<img
|
||||||
src={`data:image/png;base64,${userStatus.appMetadata.appIconB64}`}
|
src={`data:image/png;base64,${userStatus.presenceStatus.graphicsB64}`}
|
||||||
alt="Friend's active app icon"
|
alt="Friend's active app icon"
|
||||||
class="size-4"
|
class="size-4"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
import { type DollDto } from "../../../types/bindings/DollDto";
|
import { type DollDto } from "../../../types/bindings/DollDto";
|
||||||
import type { UserBasicDto } from "../../../types/bindings/UserBasicDto";
|
import type { UserBasicDto } from "../../../types/bindings/UserBasicDto";
|
||||||
import type { SendInteractionDto } from "../../../types/bindings/SendInteractionDto";
|
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 doll: DollDto;
|
||||||
export let user: UserBasicDto;
|
export let user: UserBasicDto;
|
||||||
export let userStatus: FriendUserStatus | undefined = undefined;
|
export let userStatus: UserStatus | undefined = undefined;
|
||||||
export let receivedMessage: string | undefined = undefined;
|
export let receivedMessage: string | undefined = undefined;
|
||||||
|
|
||||||
let showMessageInput = false;
|
let showMessageInput = false;
|
||||||
@@ -49,15 +49,15 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if userStatus}
|
{#if userStatus}
|
||||||
<div class="card bg-base-200 px-2 py-1 flex flex-row gap-2 items-center">
|
<div class="card bg-base-200 px-2 py-1 flex flex-row gap-2 items-center">
|
||||||
{#if userStatus.appMetadata.appIconB64}
|
{#if userStatus.presenceStatus.graphicsB64}
|
||||||
<img
|
<img
|
||||||
src={`data:image/png;base64,${userStatus.appMetadata.appIconB64}`}
|
src={`data:image/png;base64,${userStatus.presenceStatus.graphicsB64}`}
|
||||||
alt="Friend's active app icon"
|
alt="Friend's active app icon"
|
||||||
class="size-3"
|
class="size-3"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<p class="text-[0.6rem] font-mono text-ellipsis line-clamp-1">
|
<p class="text-[0.6rem] font-mono text-ellipsis line-clamp-1">
|
||||||
{userStatus.appMetadata.localized}
|
{userStatus.presenceStatus.title}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -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, };
|
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export type UpdateUserDto = Record<string, never>;
|
export type PresenceStatus = { title: string | null, subtitle: string | null, graphicsB64: string | null, };
|
||||||
Reference in New Issue
Block a user