websocket cursor data broadcast

This commit is contained in:
2025-11-29 22:35:20 +08:00
parent 723c31afa3
commit 03934c4a03
18 changed files with 594 additions and 74 deletions

View File

@@ -2,3 +2,4 @@ API_BASE_URL=http://127.0.0.1:3000
AUTH_URL=https://auth.example.com/realms/friendolls/protocol/openid-connect/auth
JWT_AUDIENCE=friendolls-desktop
REDIRECT_URI=http://localhost:8582/callback
REDIRECT_HOST=localhost:8582

144
src-tauri/Cargo.lock generated
View File

@@ -8,6 +8,12 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "adler32"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234"
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -161,6 +167,28 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "async-stream"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
dependencies = [
"async-stream-impl",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-stream-impl"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
]
[[package]]
name = "async-task"
version = "4.7.1"
@@ -213,6 +241,17 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "backoff"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1"
dependencies = [
"getrandom 0.2.16",
"instant",
"rand 0.8.5",
]
[[package]]
name = "base64"
version = "0.21.7"
@@ -656,6 +695,12 @@ dependencies = [
"syn 2.0.110",
]
[[package]]
name = "data-encoding"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
[[package]]
name = "dbus"
version = "0.9.9"
@@ -1051,6 +1096,7 @@ dependencies = [
"once_cell",
"rand 0.9.2",
"reqwest",
"rust_socketio",
"serde",
"serde_json",
"sha2",
@@ -1852,6 +1898,15 @@ dependencies = [
"cfb",
]
[[package]]
name = "instant"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
dependencies = [
"cfg-if",
]
[[package]]
name = "ipnet"
version = "2.11.0"
@@ -3315,6 +3370,50 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rust_engineio"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3d3572ceba6c5d79eecedf3be93640ca9512fa4100dff6a70f96c514adf4f1f"
dependencies = [
"adler32",
"async-stream",
"async-trait",
"base64 0.21.7",
"bytes",
"futures-util",
"http",
"native-tls",
"reqwest",
"serde",
"serde_json",
"thiserror 1.0.69",
"tokio",
"tokio-tungstenite",
"tungstenite",
"url",
]
[[package]]
name = "rust_socketio"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a6a8672db895d567b3c0b8a4c0d3e98113ebb32badf6ce66004e743e5ee1e1e"
dependencies = [
"adler32",
"backoff",
"base64 0.21.7",
"bytes",
"log",
"native-tls",
"rand 0.8.5",
"rust_engineio",
"serde",
"serde_json",
"thiserror 1.0.69",
"url",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -3691,6 +3790,17 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -4460,6 +4570,20 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38"
dependencies = [
"futures-util",
"log",
"native-tls",
"tokio",
"tokio-native-tls",
"tungstenite",
]
[[package]]
name = "tokio-util"
version = "0.7.16"
@@ -4720,6 +4844,26 @@ dependencies = [
"termcolor",
]
[[package]]
name = "tungstenite"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"native-tls",
"rand 0.8.5",
"sha1",
"thiserror 1.0.69",
"url",
"utf-8",
]
[[package]]
name = "typeid"
version = "1.0.3"

View File

@@ -39,6 +39,7 @@ tracing = "0.1"
tracing-subscriber = "0.3"
once_cell = "1"
flate2 = "1.0.28"
rust_socketio = "0.6.0"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-global-shortcut = "2"

View File

@@ -2,9 +2,11 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"windows": ["main", "scene", "preferences"],
"permissions": [
"core:default",
"opener:default"
"opener:default",
"core:event:allow-listen",
"core:event:allow-unlisten"
]
}

View File

@@ -3,7 +3,7 @@ use tauri_plugin_positioner::WindowExt;
use tracing::{error, info};
use crate::{
core::services::auth::get_tokens,
core::services::{auth::get_tokens, preferences::create_preferences_window},
get_app_handle,
services::overlay::{overlay_fullscreen, SCENE_WINDOW_LABEL},
};
@@ -16,7 +16,8 @@ pub async fn init_session() {
match get_tokens().await {
Some(_) => {
info!("User session restored");
create_scene().await;
create_scene();
create_preferences_window();
}
None => {
info!("No active session, user needs to authenticate");
@@ -24,14 +25,15 @@ pub async fn init_session() {
info!("Authentication successful, creating scene...");
tauri::async_runtime::spawn(async {
info!("Creating scene after auth success...");
create_scene().await;
create_scene();
create_preferences_window();
});
});
}
}
}
pub async fn create_scene() {
pub fn create_scene() {
info!("Starting scene creation...");
let webview_window = match tauri::WebviewWindowBuilder::new(
get_app_handle(),

View File

@@ -371,9 +371,15 @@ pub async fn exchange_code_for_auth_pass(
) -> Result<AuthPass, OAuthError> {
let (app_config, http_client) = {
let guard = lock_r!(FDOLL);
let clients = guard.clients.as_ref();
if clients.is_none() {
error!("Clients not initialized yet!");
return Err(OAuthError::InvalidConfig);
}
info!("HTTP client retrieved successfully for token exchange");
(
guard.app_config.clone().ok_or(OAuthError::InvalidConfig)?,
guard.http_client.clone(),
guard.app_config.clone(),
clients.unwrap().http_client.clone(),
)
};
@@ -389,13 +395,35 @@ pub async fn exchange_code_for_auth_pass(
.finish();
info!("Exchanging authorization code for tokens");
info!("Token endpoint URL: {}", url);
info!("Request body length: {} bytes", body.len());
let exchange_request = http_client
.post(url)
.post(url.clone())
.header("Content-Type", "application/x-www-form-urlencoded")
.body(body);
let exchange_request_response = exchange_request.send().await?;
info!("Sending token exchange request...");
let exchange_request_response = match exchange_request.send().await {
Ok(resp) => {
info!("Received response with status: {}", resp.status());
resp
}
Err(e) => {
error!("Failed to send token exchange request: {}", e);
error!("Error details: {:?}", e);
if e.is_timeout() {
error!("Request timed out");
}
if e.is_connect() {
error!("Connection error - check network and DNS");
}
if e.is_request() {
error!("Request error - check request format");
}
return Err(OAuthError::NetworkError(e));
}
};
if !exchange_request_response.status().is_success() {
let status = exchange_request_response.status();
@@ -446,16 +474,14 @@ pub fn init_auth_code_retrieval<F>(on_success: F)
where
F: FnOnce() + Send + 'static,
{
let app_config = match lock_r!(FDOLL).app_config.clone() {
Some(config) => config,
None => {
error!("Cannot initialize auth: app config not available");
return;
}
};
info!("init_auth_code_retrieval called");
let app_config = lock_r!(FDOLL).app_config.clone();
let opener = match APP_HANDLE.get() {
Some(handle) => handle.opener(),
Some(handle) => {
info!("APP_HANDLE retrieved successfully");
handle.opener()
}
None => {
error!("Cannot initialize auth: app handle not available");
return;
@@ -480,7 +506,10 @@ where
}
let mut url = match url::Url::parse(&format!("{}/auth", &app_config.auth.auth_url)) {
Ok(url) => url,
Ok(url) => {
info!("Parsed auth URL successfully");
url
}
Err(e) => {
error!("Invalid auth URL configuration: {}", e);
return;
@@ -501,8 +530,10 @@ where
// 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) {
Ok(s) => {
info!("Successfully bound to {}", app_config.auth.redirect_host);
s.set_nonblocking(true).unwrap();
s
}
@@ -514,7 +545,7 @@ where
info!(
"Listening on {} for /callback",
&app_config.auth.redirect_host
app_config.auth.redirect_host
);
tauri::async_runtime::spawn(async move {
@@ -571,8 +602,11 @@ where
}
});
info!("Opening auth URL: {}", url);
if let Err(e) = opener.open_url(url, None::<&str>) {
error!("Failed to open auth portal: {}", e);
} else {
info!("Successfully called open_url for auth portal");
}
}
@@ -592,8 +626,13 @@ pub async fn refresh_token(refresh_token: &str) -> Result<AuthPass, OAuthError>
let (app_config, http_client) = {
let guard = lock_r!(FDOLL);
(
guard.app_config.clone().ok_or(OAuthError::InvalidConfig)?,
guard.http_client.clone(),
guard.app_config.clone(),
guard
.clients
.as_ref()
.expect("clients present")
.http_client
.clone(),
)
};

View File

@@ -1 +1,3 @@
pub mod auth;
pub mod preferences;
pub mod ws;

View File

@@ -0,0 +1,35 @@
use tracing::{error, info};
use crate::get_app_handle;
pub fn create_preferences_window() {
let webview_window = match tauri::WebviewWindowBuilder::new(
get_app_handle(),
"preferences",
tauri::WebviewUrl::App("/preferences".into()),
)
.title("Friendolls Preferences")
.inner_size(600.0, 500.0)
.resizable(true)
.decorations(true)
.transparent(false)
.shadow(true)
.visible(true)
.skip_taskbar(false)
.always_on_top(false)
.visible_on_all_workspaces(false)
.build()
{
Ok(window) => {
info!("Preferences window builder succeeded");
window
}
Err(e) => {
error!("Failed to build Preferences window: {}", e);
return;
}
};
#[cfg(debug_assertions)]
webview_window.open_devtools();
}

View File

@@ -0,0 +1,63 @@
use rust_socketio::{ClientBuilder, Payload, RawClient};
use serde_json::json;
use tauri::async_runtime;
use tracing::error;
use crate::{
core::{models::app_config::AppConfig, state::FDOLL},
lock_r,
services::cursor::CursorPosition,
};
// Define a callback for handling incoming messages (e.g., 'pong')
fn on_pong(payload: Payload, _socket: RawClient) {
match payload {
Payload::Text(str) => println!("Received pong: {:?}", str),
Payload::Binary(bin) => println!("Received pong (binary): {:?}", bin),
_ => todo!(),
}
}
pub async fn report_cursor_data(cursor_position: CursorPosition) {
let client = {
let guard = lock_r!(FDOLL);
guard
.clients
.as_ref()
.expect("Clients are initialized")
.ws_client
.as_ref()
.expect("WebSocket client is initialized")
.clone()
};
match async_runtime::spawn_blocking(move || {
client.emit(
"cursor-report-position",
Payload::Text(vec![json!({ "position": cursor_position })]),
)
})
.await
{
Ok(Ok(_)) => (),
Ok(Err(e)) => error!("Failed to emit ping: {}", e),
Err(e) => error!("Failed to execute blocking task: {}", e),
}
}
pub fn build_ws_client(app_config: &AppConfig) -> rust_socketio::client::Client {
let client = match ClientBuilder::new(
app_config
.api_base_url
.as_ref()
.expect("Missing API base URL"),
)
.namespace("/")
.on("pong", on_pong)
.connect()
{
Ok(c) => c,
Err(_) => todo!("TODO error handling"),
};
client
}

View File

@@ -1,15 +1,20 @@
// in app-core/src/state.rs
use crate::{
core::models::app_config::{AppConfig, AuthConfig},
core::services::auth::{load_auth_pass, AuthPass},
core::{
models::app_config::{AppConfig, AuthConfig},
services::{
auth::{load_auth_pass, AuthPass},
ws::build_ws_client,
},
},
lock_w,
};
use reqwest::Client;
use std::{
env,
sync::{Arc, LazyLock, RwLock},
};
use tracing::warn;
use tauri::async_runtime;
use tracing::{info, warn};
#[derive(Default, Clone)]
pub struct OAuthFlowTracker {
@@ -18,10 +23,15 @@ pub struct OAuthFlowTracker {
pub initiated_at: Option<u64>,
}
pub struct Clients {
pub http_client: reqwest::Client,
pub ws_client: Option<rust_socketio::client::Client>,
}
#[derive(Default)]
pub struct AppState {
pub app_config: Option<AppConfig>,
pub http_client: Client,
pub app_config: AppConfig,
pub clients: Option<Clients>,
pub auth_pass: Option<AuthPass>,
pub oauth_flow: OAuthFlowTracker,
}
@@ -35,7 +45,7 @@ pub fn init_fdoll_state() {
{
let mut guard = lock_w!(FDOLL);
dotenvy::dotenv().ok();
guard.app_config = Some(AppConfig {
guard.app_config = AppConfig {
api_base_url: Some(env::var("API_BASE_URL").expect("API_BASE_URL must be set")),
auth: AuthConfig {
audience: env::var("JWT_AUDIENCE").expect("JWT_AUDIENCE must be set"),
@@ -43,7 +53,7 @@ pub fn init_fdoll_state() {
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() {
Ok(pass) => pass,
Err(e) => {
@@ -51,9 +61,42 @@ pub fn init_fdoll_state() {
None
}
};
guard.http_client = reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none())
info!("Loaded auth pass");
// Initialize HTTP client immediately (non-blocking)
let http_client = reqwest::ClientBuilder::new()
.timeout(std::time::Duration::from_secs(30))
.connect_timeout(std::time::Duration::from_secs(10))
.user_agent("friendolls-desktop/0.1.0")
.build()
.expect("Client should build");
// Store HTTP client immediately - WebSocket client will be added later
guard.clients = Some(Clients {
http_client,
ws_client: None,
});
info!("Initialized HTTP client");
// Clone app_config for async task
let app_config = guard.app_config.clone();
// Drop the write lock before spawning async task
drop(guard);
// Initialize WebSocket client in a blocking task to avoid runtime conflicts
async_runtime::spawn(async move {
let ws_client = async_runtime::spawn_blocking(move || build_ws_client(&app_config))
.await
.expect("Failed to initialize WebSocket client");
let mut guard = lock_w!(FDOLL);
if let Some(clients) = guard.clients.as_mut() {
clients.ws_client = Some(ws_client);
}
info!("Initialized FDOLL state with WebSocket client");
});
info!("Initialized FDOLL state (WebSocket client initializing asynchronously)");
}
}

View File

@@ -1,4 +1,4 @@
use crate::services::cursor::channel_cursor_positions;
use crate::services::cursor::start_cursor_tracking;
use tauri::async_runtime;
use tracing_subscriber;
@@ -48,7 +48,7 @@ pub fn run() {
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_positioner::init())
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![channel_cursor_positions])
.invoke_handler(tauri::generate_handler![start_cursor_tracking])
.setup(|app| {
APP_HANDLE
.set(app.handle().to_owned())

View File

@@ -1,7 +1,11 @@
use device_query::{DeviceEvents, DeviceEventsHandler};
use once_cell::sync::OnceCell;
use serde::Serialize;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tauri::ipc::Channel;
use tauri::Emitter;
use tracing::{error, info, warn};
use crate::get_app_handle;
@@ -19,6 +23,8 @@ pub struct CursorPositions {
pub mapped: CursorPosition,
}
static CURSOR_TRACKER: OnceCell<()> = OnceCell::new();
fn map_to_grid(
pos: &CursorPosition,
grid_size: i32,
@@ -31,17 +37,90 @@ fn map_to_grid(
}
}
/// Initialize cursor tracking - can be called multiple times safely from any window
/// Only the first call will actually start tracking, subsequent calls are no-ops
#[tauri::command]
pub async fn channel_cursor_positions(on_event: Channel<CursorPositions>) {
pub async fn start_cursor_tracking() -> Result<(), String> {
info!("start_cursor_tracking called");
// Use OnceCell to ensure this only runs once, even if called from multiple windows
CURSOR_TRACKER.get_or_init(|| {
info!("First call to start_cursor_tracking - spawning cursor tracking task");
tauri::async_runtime::spawn(async {
if let Err(e) = init_cursor_tracking().await {
error!("Failed to initialize cursor tracking: {}", e);
}
});
});
info!("Cursor tracking initialization registered");
Ok(())
}
async fn init_cursor_tracking() -> Result<(), String> {
info!("Initializing cursor tracking...");
let app_handle = get_app_handle();
let primary_monitor = app_handle.primary_monitor().unwrap().unwrap();
// Get primary monitor with retries
let primary_monitor = {
let mut retry_count = 0;
let max_retries = 3;
loop {
match app_handle.primary_monitor() {
Ok(Some(monitor)) => {
info!("Primary monitor acquired");
break monitor;
}
Ok(None) => {
retry_count += 1;
if retry_count >= max_retries {
return Err(format!(
"No primary monitor found after {} retries",
max_retries
));
}
warn!(
"Primary monitor not available, retrying... ({}/{})",
retry_count, max_retries
);
tokio::time::sleep(Duration::from_millis(100)).await;
}
Err(e) => {
retry_count += 1;
if retry_count >= max_retries {
return Err(format!("Failed to get primary monitor: {}", e));
}
warn!(
"Error getting primary monitor, retrying... ({}/{}): {}",
retry_count, max_retries, e
);
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
}
};
let monitor_dimensions = primary_monitor.size();
let logical_monitor_dimensions: tauri::LogicalSize<i32> =
monitor_dimensions.to_logical(primary_monitor.scale_factor());
let device_state =
DeviceEventsHandler::new(Duration::from_millis(200)).expect("Failed to start event loop");
let _guard = device_state.on_mouse_move(move |position| {
info!(
"Monitor dimensions: {}x{}",
logical_monitor_dimensions.width, logical_monitor_dimensions.height
);
// Try to initialize the device event handler
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();
let _guard = device_state.on_mouse_move(move |position: &(i32, i32)| {
let raw = CursorPosition {
x: position.0,
y: position.1,
@@ -52,13 +131,36 @@ pub async fn channel_cursor_positions(on_event: Channel<CursorPositions>) {
logical_monitor_dimensions.width,
logical_monitor_dimensions.height,
);
let positions = CursorPositions { raw, mapped };
let _ = on_event.send(positions);
let positions = CursorPositions {
raw,
mapped: mapped.clone(),
};
// Report to server (existing functionality)
let mapped_for_ws = mapped.clone();
tauri::async_runtime::spawn(async move {
crate::core::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);
}
}
});
info!("Mouse move handler registered - now broadcasting cursor events to all windows");
// Keep the handler alive forever
loop {
// for whatever reason this sleep is not taking effect but it
// does reduce CPU usage on my Mac from 100% to 6% so...cool!
tokio::time::sleep(Duration::from_millis(1000)).await
tokio::time::sleep(Duration::from_secs(3600)).await;
}
}