From 3094d9de3d2c747979eec72bc81907039daa65aa Mon Sep 17 00:00:00 2001 From: Wind-Explorer Date: Thu, 18 Dec 2025 22:51:14 +0800 Subject: [PATCH] minor refinements --- package.json | 5 + pnpm-workspace.yaml | 2 - src-tauri/.env.example | 2 - src-tauri/Cargo.lock | 198 +++++++++++++++++++++++++++- src-tauri/Cargo.toml | 2 + src-tauri/capabilities/default.json | 1 + src-tauri/src/app.rs | 1 + src-tauri/src/lib.rs | 29 +++- src-tauri/src/models/app_config.rs | 2 - src-tauri/src/services/auth.rs | 107 +++++++-------- src-tauri/src/services/cursor.rs | 106 ++++++++------- src-tauri/src/services/ws.rs | 154 +++++++++++++--------- src-tauri/src/state.rs | 90 ++++++++----- src/routes/scene/+page.svelte | 18 ++- 14 files changed, 505 insertions(+), 212 deletions(-) delete mode 100644 pnpm-workspace.yaml diff --git a/package.json b/package.json index 851187c..7e84236 100644 --- a/package.json +++ b/package.json @@ -28,5 +28,10 @@ "svelte-check": "^4.0.0", "typescript": "~5.6.2", "vite": "^6.0.3" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild" + ] } } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml deleted file mode 100644 index efc037a..0000000 --- a/pnpm-workspace.yaml +++ /dev/null @@ -1,2 +0,0 @@ -onlyBuiltDependencies: - - esbuild diff --git a/src-tauri/.env.example b/src-tauri/.env.example index 2b94f48..6297a17 100644 --- a/src-tauri/.env.example +++ b/src-tauri/.env.example @@ -1,5 +1,3 @@ API_BASE_URL=http://127.0.0.1:3000 AUTH_URL=https://auth.example.com/realms/friendolls/protocol/openid-connect JWT_AUDIENCE=friendolls-desktop -REDIRECT_URI=http://localhost:8582/callback -REDIRECT_HOST=localhost:8582 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 61aabdb..2cd3d66 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -53,6 +53,27 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "ashpd" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df" +dependencies = [ + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.2", + "raw-window-handle", + "serde", + "serde_repr", + "tokio", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -803,6 +824,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ "bitflags 2.10.0", + "block2 0.6.2", + "libc", "objc2 0.6.3", ] @@ -817,6 +840,15 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "dlib" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +dependencies = [ + "libloading", +] + [[package]] name = "dlopen2" version = "0.8.0" @@ -846,6 +878,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -1102,12 +1140,14 @@ dependencies = [ "sha2", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-global-shortcut", "tauri-plugin-opener", "tauri-plugin-positioner", "thiserror 1.0.69", "tokio", "tracing", + "tracing-appender", "tracing-subscriber", "ts-rs", "url", @@ -2962,7 +3002,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.12.0", - "quick-xml", + "quick-xml 0.38.4", "serde", "time", ] @@ -3091,6 +3131,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -3356,6 +3405,31 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2 0.6.2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2 0.6.3", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + [[package]] name = "ring" version = "0.17.14" @@ -3550,6 +3624,12 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -4248,6 +4328,46 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "313f8138692ddc4a2127c4c9607d616a46f5c042e77b3722450866da0aad2f19" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.17", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47df422695255ecbe7bac7012440eddaeefd026656171eac9559f5243d3230d9" +dependencies = [ + "anyhow", + "dunce", + "glob", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.17", + "toml 0.9.8", + "url", +] + [[package]] name = "tauri-plugin-global-shortcut" version = "2.3.0" @@ -4534,8 +4654,10 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -4748,6 +4870,18 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" +dependencies = [ + "crossbeam-channel", + "thiserror 2.0.17", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.30" @@ -5162,6 +5296,66 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wayland-backend" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" +dependencies = [ + "bitflags 2.10.0", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" +dependencies = [ + "proc-macro2", + "quick-xml 0.37.5", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.77" @@ -5908,6 +6102,7 @@ dependencies = [ "ordered-stream", "serde", "serde_repr", + "tokio", "tracing", "uds_windows", "uuid", @@ -6048,6 +6243,7 @@ dependencies = [ "endi", "enumflags2", "serde", + "url", "winnow 0.7.13", "zvariant_derive", "zvariant_utils", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 306fb5d..49dba2e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -40,6 +40,8 @@ tracing-subscriber = "0.3" once_cell = "1" flate2 = "1.0.28" rust_socketio = "0.6.0" +tracing-appender = "0.2.4" +tauri-plugin-dialog = "2.4.2" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-global-shortcut = "2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index fdf8ce2..67e9b29 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -6,6 +6,7 @@ "permissions": [ "core:default", "opener:default", + "dialog:default", "core:event:allow-listen", "core:event:allow-unlisten", "core:window:allow-close" diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs index 3bff1c7..bffdee5 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -13,6 +13,7 @@ pub async fn start_fdoll() { async fn construct_app() { init_app_data().await; + crate::services::ws::init_ws_client().await; open_scene_window(); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8501e6e..bc37c4b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ use crate::{ state::{init_app_data, FDOLL}, }; use tauri::async_runtime; +use tauri::Manager; use tracing_subscriber; static APP_HANDLE: std::sync::OnceLock> = std::sync::OnceLock::new(); @@ -29,11 +30,37 @@ pub fn get_app_handle<'a>() -> &'a tauri::AppHandle { fn setup_fdoll() -> Result<(), tauri::Error> { // Initialize tracing subscriber for logging + + // Set up file appender + let app_handle = get_app_handle(); + let app_log_dir = app_handle + .path() + .app_log_dir() + .expect("Could not determine app log dir"); + + // Create the directory if it doesn't exist + if let Err(e) = std::fs::create_dir_all(&app_log_dir) { + eprintln!("Failed to create log directory: {}", e); + } + + let file_appender = tracing_appender::rolling::daily(&app_log_dir, "friendolls.log"); + let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender); + + // Keep the guard alive? + // Actually `_guard` will be dropped here, which might stop logging. + // Ideally we should store the guard in the app state or use a global lazy_static if we want it to persist. + // However, `tracing_appender` docs say: "WorkerGuard should be assigned in the main function or whatever the entrypoint of the program is." + // Since we are inside `setup_fdoll` which is called from `setup`, we might lose logs if we drop it. + // But for simplicity in this context, we can just let it leak or store it in a static. + // Let's leak it for now as this is a long-running app. + Box::leak(Box::new(_guard)); + tracing_subscriber::fmt() .with_target(false) .with_thread_ids(false) .with_file(true) .with_line_number(true) + .with_writer(non_blocking) // Log to file .init(); state::init_fdoll_state(); @@ -153,7 +180,7 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_positioner::init()) - .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_dialog::init()) .invoke_handler(tauri::generate_handler![ start_cursor_tracking, get_app_data, diff --git a/src-tauri/src/models/app_config.rs b/src-tauri/src/models/app_config.rs index 6cf4bef..6353622 100644 --- a/src-tauri/src/models/app_config.rs +++ b/src-tauri/src/models/app_config.rs @@ -4,8 +4,6 @@ use serde::{Deserialize, Serialize}; pub struct AuthConfig { pub audience: String, pub auth_url: String, - pub redirect_uri: String, - pub redirect_host: String, } #[derive(Default, Serialize, Deserialize, Clone, Debug)] diff --git a/src-tauri/src/services/auth.rs b/src-tauri/src/services/auth.rs index aaeaafe..fb8c004 100644 --- a/src-tauri/src/services/auth.rs +++ b/src-tauri/src/services/auth.rs @@ -318,7 +318,7 @@ pub fn clear_auth_pass() -> Result<(), OAuthError> { /// # Example /// /// ```rust,no_run -/// use crate::core::services::auth::logout; +/// use crate::services::auth::logout; /// /// logout().expect("Failed to logout"); /// ``` @@ -342,7 +342,7 @@ pub fn logout() -> Result<(), OAuthError> { /// # Example /// /// ```rust,no_run -/// use crate::core::services::auth::with_auth; +/// use crate::services::auth::with_auth; /// /// let client = reqwest::Client::new(); /// let request = client.get("https://api.example.com/user"); @@ -370,6 +370,7 @@ pub async fn with_auth(request: reqwest::RequestBuilder) -> reqwest::RequestBuil /// /// Returns `OAuthError` if the exchange fails or the server returns an error. pub async fn exchange_code_for_auth_pass( + redirect_uri: &str, callback_params: OAuthCallbackParams, code_verifier: &str, ) -> Result { @@ -393,7 +394,7 @@ pub async fn exchange_code_for_auth_pass( let body = form_urlencoded::Serializer::new(String::new()) .append_pair("client_id", &app_config.auth.audience) .append_pair("grant_type", "authorization_code") - .append_pair("redirect_uri", &app_config.auth.redirect_uri) + .append_pair("redirect_uri", redirect_uri) .append_pair("code", &callback_params.code) .append_pair("code_verifier", code_verifier) .finish(); @@ -469,7 +470,7 @@ pub async fn exchange_code_for_auth_pass( /// # Example /// /// ```rust,no_run -/// use crate::core::services::auth::init_auth_code_retrieval; +/// use crate::services::auth::init_auth_code_retrieval; /// /// init_auth_code_retrieval(); /// // User will be prompted to login in their browser @@ -511,24 +512,19 @@ where } }; - url.query_pairs_mut() - .append_pair("client_id", &app_config.auth.audience) - .append_pair("response_type", "code") - .append_pair("redirect_uri", &app_config.auth.redirect_uri) - .append_pair("scope", "openid email profile") - .append_pair("state", &state) - .append_pair("code_challenge", &code_challenge) - .append_pair("code_challenge_method", "S256"); - info!("Initiating OAuth flow"); // Bind the server FIRST to ensure port is open // We bind synchronously using std::net::TcpListener then convert to tokio::net::TcpListener // to ensure the port is bound before we open the browser. - info!("Attempting to bind to: {}", app_config.auth.redirect_host); - let std_listener = match std::net::TcpListener::bind(&app_config.auth.redirect_host) { + + // Bind to port 0 (ephemeral port), + // The OS will assign an available port. + let bind_addr = "localhost:0"; + + info!("Attempting to bind to: {}", bind_addr); + let std_listener = match std::net::TcpListener::bind(&bind_addr) { Ok(s) => { - info!("Successfully bound to {}", app_config.auth.redirect_host); s.set_nonblocking(true).unwrap(); s } @@ -538,68 +534,75 @@ where } }; - info!( - "Listening on {} for /callback", - app_config.auth.redirect_host - ); + // Get the actual port assigned by the OS + let local_addr = std_listener + .local_addr() + .map_err(|e| OAuthError::ServerBindError(e.to_string()))?; + let port = local_addr.port(); + info!("Successfully bound to {}", local_addr); + info!("Listening on port {} for /callback", port); + let redirect_uri = format!("http://localhost:{}/callback", port); + + url.query_pairs_mut() + .append_pair("client_id", &app_config.auth.audience) + .append_pair("response_type", "code") + .append_pair("redirect_uri", &redirect_uri) + .append_pair("scope", "openid email profile") + .append_pair("state", &state) + .append_pair("code_challenge", &code_challenge) + .append_pair("code_challenge_method", "S256"); + let redirect_uri_clone = redirect_uri.clone(); tauri::async_runtime::spawn(async move { + info!("Starting callback listener task"); let listener = match TcpListener::from_std(std_listener) { Ok(l) => l, Err(e) => { - error!("Failed to create async listener: {}", e); + error!("Failed to convert listener: {}", e); return; } }; match listen_for_callback(listener).await { - Ok(callback_params) => { - // Validate state - let stored_state = lock_r!(FDOLL).oauth_flow.state.clone(); + Ok(params) => { + let (stored_state, stored_verifier) = { + let guard = lock_r!(FDOLL); + ( + guard.oauth_flow.state.clone(), + guard.oauth_flow.code_verifier.clone(), + ) + }; - if stored_state.as_ref() != Some(&callback_params.state) { - error!("State mismatch - possible CSRF attack!"); + if stored_state.as_deref() != Some(params.state.as_str()) { + error!("State mismatch"); return; } - // Retrieve code_verifier - let code_verifier = match lock_r!(FDOLL).oauth_flow.code_verifier.clone() { - Some(cv) => cv, - None => { - error!("Code verifier not found in state"); - return; - } + let Some(code_verifier) = stored_verifier else { + error!("Code verifier missing"); + return; }; - // Clear OAuth flow state after successful callback - lock_w!(FDOLL).oauth_flow = Default::default(); - - match exchange_code_for_auth_pass(callback_params, &code_verifier).await { + match exchange_code_for_auth_pass(&redirect_uri_clone, params, &code_verifier).await + { Ok(auth_pass) => { - lock_w!(FDOLL).auth_pass = Some(auth_pass.clone()); + { + let mut guard = lock_w!(FDOLL); + guard.auth_pass = Some(auth_pass.clone()); + guard.oauth_flow = Default::default(); + } if let Err(e) = save_auth_pass(&auth_pass) { error!("Failed to save auth pass: {}", e); - } else { - info!("Authentication successful!"); - crate::services::ws::init_ws_client().await; - on_success(); - return; } + on_success(); } - Err(e) => { - error!("Failed to exchange code for tokens: {}", e); - } + Err(e) => error!("Token exchange failed: {}", e), } } - Err(e) => { - error!("Failed to receive callback: {}", e); - // Clear OAuth flow state on error - lock_w!(FDOLL).oauth_flow = Default::default(); - } + Err(e) => error!("Callback listener error: {}", e), } }); - info!("Opening auth URL: {}", url); if let Err(e) = app_handle.opener().open_url(url, None::<&str>) { error!("Failed to open auth portal: {}", e); return Err(OAuthError::OpenPortalFailed(e)); diff --git a/src-tauri/src/services/cursor.rs b/src-tauri/src/services/cursor.rs index f9ad2a1..3699377 100644 --- a/src-tauri/src/services/cursor.rs +++ b/src-tauri/src/services/cursor.rs @@ -1,11 +1,10 @@ use device_query::{DeviceEvents, DeviceEventsHandler}; use once_cell::sync::OnceCell; use serde::{Deserialize, Serialize}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Arc; use std::time::Duration; use tauri::Emitter; -use tracing::{error, info}; +use tokio::sync::mpsc; +use tracing::{debug, error, info, warn}; use ts_rs::TS; use crate::{get_app_handle, lock_r, state::FDOLL}; @@ -14,8 +13,8 @@ use crate::{get_app_handle, lock_r, state::FDOLL}; #[serde(rename_all = "camelCase")] #[ts(export)] pub struct CursorPosition { - pub x: i32, - pub y: i32, + pub x: f64, + pub y: f64, } #[derive(Debug, Clone, Serialize, TS)] @@ -28,29 +27,27 @@ pub struct CursorPositions { static CURSOR_TRACKER: OnceCell<()> = OnceCell::new(); -/// Convert absolute screen coordinates to grid coordinates -pub fn absolute_position_to_grid(pos: &CursorPosition) -> CursorPosition { +/// Convert absolute screen coordinates to normalized coordinates (0.0 - 1.0) +pub fn absolute_to_normalized(pos: &CursorPosition) -> CursorPosition { let guard = lock_r!(FDOLL); - let grid_size = guard.app_data.scene.grid_size; - let screen_w = guard.app_data.scene.display.screen_width; - let screen_h = guard.app_data.scene.display.screen_height; + let screen_w = guard.app_data.scene.display.screen_width as f64; + let screen_h = guard.app_data.scene.display.screen_height as f64; CursorPosition { - x: pos.x * grid_size / screen_w, - y: pos.y * grid_size / screen_h, + x: (pos.x / screen_w).clamp(0.0, 1.0), + y: (pos.y / screen_h).clamp(0.0, 1.0), } } -/// Convert grid coordinates to absolute screen coordinates -pub fn grid_to_absolute_position(grid: &CursorPosition) -> CursorPosition { +/// Convert normalized coordinates to absolute screen coordinates +pub fn normalized_to_absolute(normalized: &CursorPosition) -> CursorPosition { let guard = lock_r!(FDOLL); - let grid_size = guard.app_data.scene.grid_size; - let screen_w = guard.app_data.scene.display.screen_width; - let screen_h = guard.app_data.scene.display.screen_height; + let screen_w = guard.app_data.scene.display.screen_width as f64; + let screen_h = guard.app_data.scene.display.screen_height as f64; CursorPosition { - x: (grid.x * screen_w + grid_size / 2) / grid_size, - y: (grid.y * screen_h + grid_size / 2) / grid_size, + x: (normalized.x * screen_w).round(), + y: (normalized.y * screen_h).round(), } } @@ -76,19 +73,40 @@ pub async fn start_cursor_tracking() -> Result<(), String> { async fn init_cursor_tracking() -> Result<(), String> { info!("Initializing cursor tracking..."); - let app_handle = get_app_handle(); + + // Create a channel to decouple event generation (producer) from processing (consumer). + // Capacity 100 is plenty for 500ms polling (2Hz). + let (tx, mut rx) = mpsc::channel::(100); + + // Spawn the consumer task + // This task handles WebSocket reporting and local broadcasting. + // It runs independently of the device event loop. + tauri::async_runtime::spawn(async move { + info!("Cursor event consumer started"); + let app_handle = get_app_handle(); + + while let Some(positions) = rx.recv().await { + let mapped_for_ws = positions.mapped.clone(); + + // 1. WebSocket reporting + crate::services::ws::report_cursor_data(mapped_for_ws).await; + + // 2. Broadcast to local windows + if let Err(e) = app_handle.emit("cursor-position", &positions) { + error!("Failed to emit cursor position event: {:?}", e); + } + } + warn!("Cursor event consumer stopped (channel closed)"); + }); // Try to initialize the device event handler + // Using 500ms sleep as requested by user to reduce CPU usage let device_state = DeviceEventsHandler::new(Duration::from_millis(500)) .ok_or("Failed to create device event handler (already running?)")?; info!("Device event handler created successfully"); info!("Setting up mouse move handler for event broadcasting..."); - let send_count = Arc::new(AtomicU64::new(0)); - let send_count_clone = Arc::clone(&send_count); - let app_handle_clone = app_handle.clone(); - // Get scale factor from global state #[cfg(target_os = "windows")] let scale_factor = { @@ -96,8 +114,11 @@ async fn init_cursor_tracking() -> Result<(), String> { guard.app_data.scene.display.monitor_scale_factor }; + // The producer closure moves `tx` into it. + // device_query runs this closure on its own thread. + // Explicitly clone tx to ensure clear capture semantics + let tx_clone = tx.clone(); let _guard = device_state.on_mouse_move(move |position: &(i32, i32)| { - // `device_query` crate appears to behave // differently on Windows vs other platforms. // @@ -105,46 +126,33 @@ async fn init_cursor_tracking() -> Result<(), String> { // factor on Windows, so we handle it manually. #[cfg(target_os = "windows")] let raw = CursorPosition { - x: (position.0 as f64 / scale_factor) as i32, - y: (position.1 as f64 / scale_factor) as i32, + x: position.0 as f64 / scale_factor, + y: position.1 as f64 / scale_factor, }; #[cfg(not(target_os = "windows"))] let raw = CursorPosition { - x: position.0, - y: position.1, + x: position.0 as f64, + y: position.1 as f64, }; - let mapped = absolute_position_to_grid(&raw); + let mapped = absolute_to_normalized(&raw); + let positions = CursorPositions { raw, - mapped: mapped.clone(), + mapped, }; - // Report to server (existing functionality) - let mapped_for_ws = mapped.clone(); - tauri::async_runtime::spawn(async move { - crate::services::ws::report_cursor_data(mapped_for_ws).await; - }); - - // Broadcast to ALL windows using events - match app_handle_clone.emit("cursor-position", &positions) { - Ok(_) => { - let count = send_count_clone.fetch_add(1, Ordering::Relaxed) + 1; - if count % 100 == 0 { - info!("Broadcast {} cursor position updates to all windows. Latest: raw({}, {}), mapped({}, {})", - count, positions.raw.x, positions.raw.y, positions.mapped.x, positions.mapped.y); - } - } - Err(e) => { - error!("Failed to emit cursor position event: {:?}", e); - } + // Send to consumer channel (non-blocking) + if let Err(e) = tx_clone.try_send(positions) { + debug!("Failed to send cursor position to channel: {:?}", e); } }); info!("Mouse move handler registered - now broadcasting cursor events to all windows"); // Keep the handler alive forever + // This loop is necessary to keep `_guard` and `device_state` in scope. loop { tokio::time::sleep(Duration::from_secs(3600)).await; } diff --git a/src-tauri/src/services/ws.rs b/src-tauri/src/services/ws.rs index ddf8f2d..93e9cf9 100644 --- a/src-tauri/src/services/ws.rs +++ b/src-tauri/src/services/ws.rs @@ -4,8 +4,9 @@ use tauri::{async_runtime, Emitter}; use tracing::{error, info}; use crate::{ - get_app_handle, lock_r, lock_w, models::app_config::AppConfig, - services::cursor::{grid_to_absolute_position, CursorPosition, CursorPositions}, + get_app_handle, lock_r, lock_w, + models::app_config::AppConfig, + services::cursor::{normalized_to_absolute, CursorPosition, CursorPositions}, state::FDOLL, }; use serde::{Deserialize, Serialize}; @@ -41,9 +42,9 @@ fn on_friend_request_received(payload: Payload, _socket: RawClient) { match payload { Payload::Text(str) => { println!("Received friend request: {:?}", str); - get_app_handle() - .emit(WS_EVENT::FRIEND_REQUEST_RECEIVED, str) - .unwrap(); + if let Err(e) = get_app_handle().emit(WS_EVENT::FRIEND_REQUEST_RECEIVED, str) { + error!("Failed to emit friend request received event: {:?}", e); + } } _ => error!("Received unexpected payload format for friend request received"), } @@ -53,9 +54,9 @@ fn on_friend_request_accepted(payload: Payload, _socket: RawClient) { match payload { Payload::Text(str) => { println!("Received friend request accepted: {:?}", str); - get_app_handle() - .emit(WS_EVENT::FRIEND_REQUEST_ACCEPTED, str) - .unwrap(); + if let Err(e) = get_app_handle().emit(WS_EVENT::FRIEND_REQUEST_ACCEPTED, str) { + error!("Failed to emit friend request accepted event: {:?}", e); + } } _ => error!("Received unexpected payload format for friend request accepted"), } @@ -65,9 +66,9 @@ fn on_friend_request_denied(payload: Payload, _socket: RawClient) { match payload { Payload::Text(str) => { println!("Received friend request denied: {:?}", str); - get_app_handle() - .emit(WS_EVENT::FRIEND_REQUEST_DENIED, str) - .unwrap(); + if let Err(e) = get_app_handle().emit(WS_EVENT::FRIEND_REQUEST_DENIED, str) { + error!("Failed to emit friend request denied event: {:?}", e); + } } _ => error!("Received unexpected payload format for friend request denied"), } @@ -77,7 +78,9 @@ fn on_unfriended(payload: Payload, _socket: RawClient) { match payload { Payload::Text(str) => { println!("Received unfriended: {:?}", str); - get_app_handle().emit(WS_EVENT::UNFRIENDED, str).unwrap(); + if let Err(e) = get_app_handle().emit(WS_EVENT::UNFRIENDED, str) { + error!("Failed to emit unfriended event: {:?}", e); + } } _ => error!("Received unexpected payload format for unfriended"), } @@ -88,15 +91,16 @@ fn on_friend_cursor_position(payload: Payload, _socket: RawClient) { Payload::Text(values) => { // values is Vec if let Some(first_value) = values.first() { - let incoming_data: Result = serde_json::from_value(first_value.clone()); + let incoming_data: Result = + serde_json::from_value(first_value.clone()); - match incoming_data { + match incoming_data { Ok(friend_data) => { - // We received grid coordinates (mapped) + // We received normalized coordinates (mapped) let mapped_pos = &friend_data.position; - // Convert grid coordinates back to absolute screen coordinates (raw) - let raw_pos = grid_to_absolute_position(mapped_pos); + // Convert normalized coordinates back to absolute screen coordinates (raw) + let raw_pos = normalized_to_absolute(mapped_pos); let outgoing_payload = OutgoingFriendCursorPayload { user_id: friend_data.user_id.clone(), @@ -106,14 +110,16 @@ fn on_friend_cursor_position(payload: Payload, _socket: RawClient) { }, }; - get_app_handle() + if let Err(e) = get_app_handle() .emit(WS_EVENT::FRIEND_CURSOR_POSITION, outgoing_payload) - .unwrap(); + { + error!("Failed to emit friend cursor position event: {:?}", e); + } } Err(e) => { error!("Failed to parse friend cursor position data: {}", e); } - } + } } else { error!("Received empty text payload for friend cursor position"); } @@ -126,38 +132,43 @@ fn on_friend_disconnected(payload: Payload, _socket: RawClient) { match payload { Payload::Text(str) => { println!("Received friend disconnected: {:?}", str); - get_app_handle() - .emit(WS_EVENT::FRIEND_DISCONNECTED, str) - .unwrap(); + if let Err(e) = get_app_handle().emit(WS_EVENT::FRIEND_DISCONNECTED, str) { + error!("Failed to emit friend disconnected event: {:?}", e); + } } _ => error!("Received unexpected payload format for friend disconnected"), } } pub async fn report_cursor_data(cursor_position: CursorPosition) { - let client = { + // Only attempt to get clients if lock_r succeeds (it should, but safety first) + // and if clients are actually initialized. + let client_opt = { let guard = lock_r!(FDOLL); guard .clients .as_ref() - .expect("Clients are initialized") - .ws_client - .as_ref() - .expect("WebSocket client is initialized") - .clone() + .and_then(|c| c.ws_client.as_ref()) + .cloned() }; - match async_runtime::spawn_blocking(move || { - client.emit( - WS_EVENT::CURSOR_REPORT_POSITION, - Payload::Text(vec![json!(cursor_position)]), - ) - }) - .await - { - Ok(Ok(_)) => (), - Ok(Err(e)) => error!("Failed to emit ping: {}", e), - Err(e) => error!("Failed to execute blocking task: {}", e), + if let Some(client) = client_opt { + match async_runtime::spawn_blocking(move || { + client.emit( + WS_EVENT::CURSOR_REPORT_POSITION, + Payload::Text(vec![json!(cursor_position)]), + ) + }) + .await + { + Ok(Ok(_)) => (), + Ok(Err(e)) => error!("Failed to emit cursor report: {}", e), + Err(e) => error!("Failed to execute blocking task for cursor report: {}", e), + } + } else { + // Quietly fail if client is not ready (e.g. during startup/shutdown) + // or debug log it. + // debug!("WebSocket client not ready to report cursor data"); } } @@ -167,28 +178,39 @@ pub async fn init_ws_client() { guard.app_config.clone() }; - let ws_client = build_ws_client(&app_config).await; - - let mut guard = lock_w!(FDOLL); - if let Some(clients) = guard.clients.as_mut() { - clients.ws_client = Some(ws_client); + match build_ws_client(&app_config).await { + Ok(ws_client) => { + let mut guard = lock_w!(FDOLL); + if let Some(clients) = guard.clients.as_mut() { + clients.ws_client = Some(ws_client); + } + info!("WebSocket client initialized after authentication"); + } + Err(e) => { + error!("Failed to initialize WebSocket client: {}", e); + // We should probably inform the user or retry here, but for now we just log it. + } } - info!("WebSocket client initialized after authentication"); } -pub async fn build_ws_client(app_config: &AppConfig) -> rust_socketio::client::Client { - let token = crate::services::auth::get_access_token() - .await - .expect("No access token available for WebSocket connection"); +pub async fn build_ws_client( + app_config: &AppConfig, +) -> Result { + let token_result = crate::services::auth::get_access_token().await; + + let token = match token_result { + Some(t) => t, + None => return Err("No access token available for WebSocket connection".to_string()), + }; info!("Building WebSocket client with authentication"); let api_base_url = app_config .api_base_url .clone() - .expect("Missing API base URL"); + .ok_or("Missing API base URL")?; - let client = async_runtime::spawn_blocking(move || { + let client_result = async_runtime::spawn_blocking(move || { ClientBuilder::new(api_base_url) .namespace("/") .on( @@ -206,20 +228,24 @@ pub async fn build_ws_client(app_config: &AppConfig) -> rust_socketio::client::C .auth(json!({ "token": token })) .connect() }) - .await - .expect("Failed to spawn blocking task"); + .await; - match client { - Ok(c) => { - info!("WebSocket client connected successfully"); - c - } + match client_result { + Ok(connect_result) => match connect_result { + Ok(c) => { + info!("WebSocket client connected successfully"); + Ok(c) + } + Err(e) => { + let err_msg = format!("Failed to connect WebSocket: {:?}", e); + error!("{}", err_msg); + Err(err_msg) + } + }, Err(e) => { - error!("Failed to connect WebSocket: {:?}", e); - panic!( - "TODO: better error handling for WebSocket connection - {}", - e - ); + let err_msg = format!("Failed to spawn blocking task: {:?}", e); + error!("{}", err_msg); + Err(err_msg) } } } diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index d52dc6f..1c56911 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,6 +1,6 @@ // in app-core/src/state.rs use crate::{ - get_app_handle, lock_w, + get_app_handle, lock_r, lock_w, models::{ app_config::{AppConfig, AuthConfig}, app_data::AppData, @@ -53,8 +53,6 @@ pub fn init_fdoll_state() { auth: AuthConfig { audience: env::var("JWT_AUDIENCE").expect("JWT_AUDIENCE must be set"), auth_url: env::var("AUTH_URL").expect("AUTH_URL must be set"), - redirect_uri: env::var("REDIRECT_URI").expect("REDIRECT_URI must be set"), - redirect_host: env::var("REDIRECT_HOST").expect("REDIRECT_HOST must be set"), }, }; guard.auth_pass = match load_auth_pass() { @@ -81,11 +79,9 @@ pub fn init_fdoll_state() { }); info!("Initialized HTTP client"); - let has_auth = guard.auth_pass.is_some(); - // Initialize screen dimensions let app_handle = get_app_handle(); - + // Get primary monitor with retries // Note: This duplicates logic from init_cursor_tracking, but we need it here for global state let primary_monitor = { @@ -100,7 +96,10 @@ pub fn init_fdoll_state() { Ok(None) => { retry_count += 1; if retry_count >= max_retries { - warn!("No primary monitor found after {} retries during state init", max_retries); + warn!( + "No primary monitor found after {} retries during state init", + max_retries + ); break None; } warn!( @@ -135,28 +134,20 @@ pub fn init_fdoll_state() { guard.app_data.scene.display.screen_height = logical_monitor_dimensions.height; guard.app_data.scene.display.monitor_scale_factor = monitor_scale_factor; guard.app_data.scene.grid_size = 600; // Hardcoded grid size - + info!( "Initialized global AppData with screen dimensions: {}x{}, scale: {}, grid: {}", - logical_monitor_dimensions.width, + logical_monitor_dimensions.width, logical_monitor_dimensions.height, monitor_scale_factor, guard.app_data.scene.grid_size ); } else { - warn!("Could not initialize screen dimensions in global state - no monitor found"); + warn!("Could not initialize screen dimensions in global state - no monitor found"); } - - drop(guard); - - if has_auth { - async_runtime::spawn(async move { - crate::services::ws::init_ws_client().await; - }); - } - - info!("Initialized FDOLL state (WebSocket client & user data initializing asynchronously)"); } + + info!("Initialized FDOLL state (WebSocket client & user data initializing asynchronously)"); } /// To be called in init state or need to refresh data. @@ -164,21 +155,52 @@ pub fn init_fdoll_state() { pub async fn init_app_data() { let user_remote = UserRemote::new(); let friend_remote = FriendRemote::new(); - let user = user_remote - .get_user(None) - .await - .expect("TODO: handle user profile fetch failure"); - let friends = friend_remote - .get_friends() - .await - .expect("TODO: handle friends fetch failure"); + // Fetch user profile + match user_remote.get_user(None).await { + Ok(user) => { + let mut guard = lock_w!(FDOLL); + guard.app_data.user = Some(user); + } + Err(e) => { + warn!("Failed to fetch user profile: {}", e); + use tauri_plugin_dialog::MessageDialogBuilder; + use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; + + let handle = get_app_handle(); + MessageDialogBuilder::new( + handle.dialog().clone(), + "Network Error", + "Failed to fetch user profile. You may be offline.", + ) + .kind(MessageDialogKind::Error) + .show(|_| {}); + // We continue execution to see if other parts (like friends) can be loaded or if we just stay in a partial state. + // Alternatively, return early here. + } + } + + // Fetch friends list + match friend_remote.get_friends().await { + Ok(friends) => { + let mut guard = lock_w!(FDOLL); + guard.app_data.friends = Some(friends); + } + Err(e) => { + warn!("Failed to fetch friends list: {}", e); + // Optionally show another dialog or just log it. + // Showing too many dialogs on startup is annoying, so we might skip this one if user profile failed too. + } + } + + // Emit event regardless of partial success, frontend should handle nulls/empty states { - let mut guard = lock_w!(FDOLL); - guard.app_data.user = Some(user); - guard.app_data.friends = Some(friends); - get_app_handle() - .emit("app-data-refreshed", json!(guard.app_data)) - .expect("TODO: handle event emit fail"); + let guard = lock_r!(FDOLL); // Use read lock to get data + let app_data_clone = guard.app_data.clone(); + drop(guard); // Drop lock before emitting to prevent potential deadlocks + + if let Err(e) = get_app_handle().emit("app-data-refreshed", json!(app_data_clone)) { + warn!("Failed to emit app-data-refreshed event: {}", e); + } } } diff --git a/src/routes/scene/+page.svelte b/src/routes/scene/+page.svelte index c177c8b..4ad3992 100644 --- a/src/routes/scene/+page.svelte +++ b/src/routes/scene/+page.svelte @@ -7,12 +7,17 @@ import DesktopPet from "./DesktopPet.svelte"; + let innerWidth = 0; + let innerHeight = 0; + function getFriendName(userId: string) { const friend = $appData?.friends?.find((f) => f.friend.id === userId); return friend ? friend.friend.name : userId.slice(0, 8) + "..."; } + +
- Mapped: ({$cursorPositionOnScreen.mapped.x}, {$cursorPositionOnScreen - .mapped.y}) + Mapped: ({$cursorPositionOnScreen.mapped.x.toFixed(3)}, {$cursorPositionOnScreen.mapped.y.toFixed( + 3, + )})
@@ -45,7 +51,9 @@ Raw: ({position.raw.x}, {position.raw.y}) - Mapped: ({position.mapped.x}, {position.mapped.y}) + Mapped: ({position.mapped.x.toFixed(3)}, {position.mapped.y.toFixed( + 3, + )})
@@ -59,8 +67,8 @@ {#if Object.keys($friendsCursorPositions).length > 0} {#each Object.entries($friendsCursorPositions) as [userId, position]} {/each}