diff --git a/src-tauri/src/services/auth.rs b/src-tauri/src/services/auth.rs deleted file mode 100644 index 1df6bd4..0000000 --- a/src-tauri/src/services/auth.rs +++ /dev/null @@ -1,504 +0,0 @@ -use crate::get_app_handle; -use crate::init::lifecycle::construct_user_session; -use crate::services::scene::close_splash_window; -use crate::services::welcome::close_welcome_window; -use crate::state::auth::get_auth_pass_with_refresh; -use crate::{lock_r, lock_w, state::FDOLL}; -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; -use flate2::{read::GzDecoder, write::GzEncoder, Compression}; -use keyring::Entry; -use serde::{Deserialize, Serialize}; -use std::io::{Read, Write}; -use std::time::{SystemTime, UNIX_EPOCH}; -use thiserror::Error; -use tracing::{error, info}; - -const SERVICE_NAME: &str = "friendolls"; - -#[derive(Debug, Error)] -pub enum AuthError { - #[error("Keyring error: {0}")] - KeyringError(#[from] keyring::Error), - - #[error("Network error: {0}")] - NetworkError(#[from] reqwest::Error), - - #[error("JSON serialization error: {0}")] - SerializationError(#[from] serde_json::Error), - - #[error("Invalid app configuration")] - InvalidConfig, - - #[error("Failed to refresh token")] - RefreshFailed, - - #[error("Request failed: {0}")] - RequestFailed(String), - - #[error("IO error: {0}")] - IoError(#[from] std::io::Error), -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct AuthPass { - pub access_token: String, - pub expires_in: u64, - pub issued_at: Option, -} - -#[derive(Debug, Deserialize)] -struct LoginResponse { - #[serde(rename = "accessToken")] - access_token: String, - #[serde(rename = "expiresIn")] - expires_in: u64, -} - -#[derive(Debug, Deserialize)] -struct RegisterResponse { - id: String, -} - -#[derive(Debug, Serialize)] -struct LoginRequest<'a> { - email: &'a str, - password: &'a str, -} - -#[derive(Debug, Serialize)] -struct RegisterRequest<'a> { - email: &'a str, - password: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - name: Option<&'a str>, - #[serde(skip_serializing_if = "Option::is_none")] - username: Option<&'a str>, -} - -#[derive(Debug, Serialize)] -struct ChangePasswordRequest<'a> { - #[serde(rename = "currentPassword")] - current_password: &'a str, - #[serde(rename = "newPassword")] - new_password: &'a str, -} - -#[derive(Debug, Serialize)] -struct ResetPasswordRequest<'a> { - #[serde(rename = "oldPassword")] - old_password: &'a str, - #[serde(rename = "newPassword")] - new_password: &'a str, -} - -fn build_auth_pass(access_token: String, expires_in: u64) -> Result { - let issued_at = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|_| AuthError::RefreshFailed)? - .as_secs(); - Ok(AuthPass { - access_token, - expires_in, - issued_at: Some(issued_at), - }) -} - -pub async fn get_session_token() -> Option { - get_auth_pass_with_refresh().await -} - -pub async fn get_access_token() -> Option { - get_session_token().await.map(|pass| pass.access_token) -} - -pub fn save_auth_pass(auth_pass: &AuthPass) -> Result<(), AuthError> { - let json = serde_json::to_string(auth_pass)?; - let mut encoder = GzEncoder::new(Vec::new(), Compression::best()); - encoder - .write_all(json.as_bytes()) - .map_err(|e| AuthError::SerializationError(serde_json::Error::io(e)))?; - let compressed = encoder - .finish() - .map_err(|e| AuthError::SerializationError(serde_json::Error::io(e)))?; - let encoded = URL_SAFE_NO_PAD.encode(&compressed); - - #[cfg(target_os = "windows")] - { - const CHUNK_SIZE: usize = 1200; - let chunks: Vec<&str> = encoded - .as_bytes() - .chunks(CHUNK_SIZE) - .map(|chunk| std::str::from_utf8(chunk).unwrap()) - .collect(); - - let count_entry = Entry::new(SERVICE_NAME, "auth_pass_count")?; - count_entry.set_password(&chunks.len().to_string())?; - - for (i, chunk) in chunks.iter().enumerate() { - let entry = Entry::new(SERVICE_NAME, &format!("auth_pass_{}", i))?; - entry.set_password(chunk)?; - } - } - - #[cfg(not(target_os = "windows"))] - { - let entry = Entry::new(SERVICE_NAME, "auth_pass")?; - entry.set_password(&encoded)?; - } - - Ok(()) -} - -pub fn load_auth_pass() -> Result, AuthError> { - #[cfg(target_os = "windows")] - let encoded = { - let count_entry = Entry::new(SERVICE_NAME, "auth_pass_count")?; - let chunk_count = match count_entry.get_password() { - Ok(count_str) => match count_str.parse::() { - Ok(count) => count, - Err(_) => { - error!("Invalid chunk count in keyring"); - return Ok(None); - } - }, - Err(keyring::Error::NoEntry) => { - info!("No auth pass found in keyring"); - return Ok(None); - } - Err(e) => { - error!("Failed to load chunk count from keyring"); - return Err(AuthError::KeyringError(e)); - } - }; - - let mut encoded = String::new(); - for i in 0..chunk_count { - let entry = Entry::new(SERVICE_NAME, &format!("auth_pass_{}", i))?; - match entry.get_password() { - Ok(chunk) => encoded.push_str(&chunk), - Err(e) => { - error!("Failed to load chunk {} from keyring", i); - return Err(AuthError::KeyringError(e)); - } - } - } - encoded - }; - - #[cfg(not(target_os = "windows"))] - let encoded = { - let entry = Entry::new(SERVICE_NAME, "auth_pass")?; - match entry.get_password() { - Ok(pass) => pass, - Err(keyring::Error::NoEntry) => { - info!("No auth pass found in keyring"); - return Ok(None); - } - Err(e) => { - error!("Failed to load auth pass from keyring"); - return Err(AuthError::KeyringError(e)); - } - } - }; - - let compressed = match URL_SAFE_NO_PAD.decode(&encoded) { - Ok(c) => c, - Err(e) => { - error!("Failed to base64 decode auth pass from keyring: {}", e); - return Ok(None); - } - }; - - let mut decoder = GzDecoder::new(&compressed[..]); - let mut json = String::new(); - if let Err(e) = decoder.read_to_string(&mut json) { - error!("Failed to decompress auth pass from keyring: {}", e); - return Ok(None); - } - - let auth_pass: AuthPass = match serde_json::from_str(&json) { - Ok(v) => v, - Err(_e) => { - error!("Failed to decode auth pass from keyring"); - return Ok(None); - } - }; - - Ok(Some(auth_pass)) -} - -pub fn clear_auth_pass() -> Result<(), AuthError> { - #[cfg(target_os = "windows")] - { - let count_entry = Entry::new(SERVICE_NAME, "auth_pass_count")?; - let chunk_count = match count_entry.get_password() { - Ok(count_str) => count_str.parse::().unwrap_or(0), - Err(_) => 0, - }; - - for i in 0..chunk_count { - let entry = Entry::new(SERVICE_NAME, &format!("auth_pass_{}", i))?; - let _ = entry.delete_credential(); - } - - let _ = count_entry.delete_credential(); - } - - #[cfg(not(target_os = "windows"))] - { - let entry = Entry::new(SERVICE_NAME, "auth_pass")?; - let _ = entry.delete_credential(); - } - - Ok(()) -} - -pub fn logout() -> Result<(), AuthError> { - info!("Logging out user"); - lock_w!(FDOLL).auth.auth_pass = None; - clear_auth_pass()?; - Ok(()) -} - -pub async fn logout_and_restart() -> Result<(), AuthError> { - logout()?; - let app_handle = get_app_handle(); - app_handle.restart(); -} - -pub async fn with_auth(request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { - if let Some(token) = get_access_token().await { - request.header("Authorization", format!("Bearer {}", token)) - } else { - request - } -} - -pub async fn login(email: &str, password: &str) -> Result { - let (app_config, http_client) = { - let guard = lock_r!(FDOLL); - let clients = guard.network.clients.as_ref(); - if clients.is_none() { - error!("Clients not initialized yet!"); - return Err(AuthError::InvalidConfig); - } - ( - guard.app_config.clone(), - clients.unwrap().http_client.clone(), - ) - }; - - let base_url = app_config - .api_base_url - .as_ref() - .ok_or(AuthError::InvalidConfig)?; - let url = format!("{}/auth/login", base_url); - - let response = http_client - .post(url) - .json(&LoginRequest { email, password }) - .send() - .await?; - - if !response.status().is_success() { - let status = response.status(); - let error_text = response.text().await.unwrap_or_default(); - return Err(AuthError::RequestFailed(format!( - "Status: {}, Body: {}", - status, error_text - ))); - } - - let login_response: LoginResponse = response.json().await?; - let auth_pass = build_auth_pass(login_response.access_token, login_response.expires_in)?; - lock_w!(FDOLL).auth.auth_pass = Some(auth_pass.clone()); - save_auth_pass(&auth_pass)?; - Ok(auth_pass) -} - -pub async fn register( - email: &str, - password: &str, - name: Option<&str>, - username: Option<&str>, -) -> Result { - let (app_config, http_client) = { - let guard = lock_r!(FDOLL); - let clients = guard.network.clients.as_ref(); - if clients.is_none() { - error!("Clients not initialized yet!"); - return Err(AuthError::InvalidConfig); - } - ( - guard.app_config.clone(), - clients.unwrap().http_client.clone(), - ) - }; - - let base_url = app_config - .api_base_url - .as_ref() - .ok_or(AuthError::InvalidConfig)?; - let url = format!("{}/auth/register", base_url); - - let response = http_client - .post(url) - .json(&RegisterRequest { - email, - password, - name, - username, - }) - .send() - .await?; - - if !response.status().is_success() { - let status = response.status(); - let error_text = response.text().await.unwrap_or_default(); - return Err(AuthError::RequestFailed(format!( - "Status: {}, Body: {}", - status, error_text - ))); - } - - let register_response: RegisterResponse = response.json().await?; - Ok(register_response.id) -} - -pub async fn change_password( - current_password: &str, - new_password: &str, -) -> Result<(), AuthError> { - let (app_config, http_client) = { - let guard = lock_r!(FDOLL); - let clients = guard.network.clients.as_ref(); - if clients.is_none() { - error!("Clients not initialized yet!"); - return Err(AuthError::InvalidConfig); - } - ( - guard.app_config.clone(), - clients.unwrap().http_client.clone(), - ) - }; - - let base_url = app_config - .api_base_url - .as_ref() - .ok_or(AuthError::InvalidConfig)?; - let url = format!("{}/auth/change-password", base_url); - - let response = with_auth( - http_client.post(url).json(&ChangePasswordRequest { - current_password, - new_password, - }), - ) - .await - .send() - .await?; - - if !response.status().is_success() { - let status = response.status(); - let error_text = response.text().await.unwrap_or_default(); - return Err(AuthError::RequestFailed(format!( - "Status: {}, Body: {}", - status, error_text - ))); - } - - Ok(()) -} - -pub async fn reset_password(old_password: &str, new_password: &str) -> Result<(), AuthError> { - let (app_config, http_client) = { - let guard = lock_r!(FDOLL); - let clients = guard.network.clients.as_ref(); - if clients.is_none() { - error!("Clients not initialized yet!"); - return Err(AuthError::InvalidConfig); - } - ( - guard.app_config.clone(), - clients.unwrap().http_client.clone(), - ) - }; - - let base_url = app_config - .api_base_url - .as_ref() - .ok_or(AuthError::InvalidConfig)?; - let url = format!("{}/auth/reset-password", base_url); - - let response = with_auth( - http_client.post(url).json(&ResetPasswordRequest { - old_password, - new_password, - }), - ) - .await - .send() - .await?; - - if !response.status().is_success() { - let status = response.status(); - let error_text = response.text().await.unwrap_or_default(); - return Err(AuthError::RequestFailed(format!( - "Status: {}, Body: {}", - status, error_text - ))); - } - - Ok(()) -} - -pub async fn refresh_token(access_token: &str) -> Result { - let (app_config, http_client) = { - let guard = lock_r!(FDOLL); - ( - guard.app_config.clone(), - guard - .network - .clients - .as_ref() - .expect("clients present") - .http_client - .clone(), - ) - }; - - let base_url = app_config - .api_base_url - .as_ref() - .ok_or(AuthError::InvalidConfig)?; - let url = format!("{}/auth/refresh", base_url); - - let response = http_client - .post(url) - .header("Authorization", format!("Bearer {}", access_token)) - .send() - .await?; - - if !response.status().is_success() { - let status = response.status(); - let error_text = response.text().await.unwrap_or_default(); - error!("Token refresh failed with status {}: {}", status, error_text); - return Err(AuthError::RefreshFailed); - } - - let refresh_response: LoginResponse = response.json().await?; - let auth_pass = build_auth_pass(refresh_response.access_token, refresh_response.expires_in)?; - lock_w!(FDOLL).auth.auth_pass = Some(auth_pass.clone()); - save_auth_pass(&auth_pass)?; - Ok(auth_pass) -} - -pub async fn login_and_init_session(email: &str, password: &str) -> Result<(), AuthError> { - login(email, password).await?; - close_welcome_window(); - tauri::async_runtime::spawn(async { - construct_user_session().await; - close_splash_window(); - }); - Ok(()) -} diff --git a/src-tauri/src/services/auth/api.rs b/src-tauri/src/services/auth/api.rs new file mode 100644 index 0000000..750644c --- /dev/null +++ b/src-tauri/src/services/auth/api.rs @@ -0,0 +1,182 @@ +use serde::{Deserialize, Serialize}; +use tracing::error; + +use crate::{lock_r, lock_w, state::FDOLL}; + +use super::storage::{build_auth_pass, save_auth_pass, AuthError, AuthPass}; + +#[derive(Debug, Deserialize)] +struct LoginResponse { + #[serde(rename = "accessToken")] + access_token: String, + #[serde(rename = "expiresIn")] + expires_in: u64, +} + +#[derive(Debug, Deserialize)] +struct RegisterResponse { + id: String, +} + +#[derive(Debug, Serialize)] +struct LoginRequest<'a> { + email: &'a str, + password: &'a str, +} + +#[derive(Debug, Serialize)] +struct RegisterRequest<'a> { + email: &'a str, + password: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + name: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + username: Option<&'a str>, +} + +#[derive(Debug, Serialize)] +struct ChangePasswordRequest<'a> { + #[serde(rename = "currentPassword")] + current_password: &'a str, + #[serde(rename = "newPassword")] + new_password: &'a str, +} + +#[derive(Debug, Serialize)] +struct ResetPasswordRequest<'a> { + #[serde(rename = "oldPassword")] + old_password: &'a str, + #[serde(rename = "newPassword")] + new_password: &'a str, +} + +fn auth_http_context() -> Result<(String, reqwest::Client), AuthError> { + let guard = lock_r!(FDOLL); + let clients = guard.network.clients.as_ref().ok_or_else(|| { + error!("Clients not initialized yet!"); + AuthError::InvalidConfig + })?; + + let base_url = guard + .app_config + .api_base_url + .clone() + .ok_or(AuthError::InvalidConfig)?; + + Ok((base_url, clients.http_client.clone())) +} + +async fn ensure_success(response: reqwest::Response) -> Result { + if response.status().is_success() { + return Ok(response); + } + + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + Err(AuthError::RequestFailed(format!( + "Status: {}, Body: {}", + status, error_text + ))) +} + +pub async fn with_auth(request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + if let Some(token) = super::session::get_access_token().await { + request.header("Authorization", format!("Bearer {}", token)) + } else { + request + } +} + +pub async fn login(email: &str, password: &str) -> Result { + let (base_url, http_client) = auth_http_context()?; + let response = http_client + .post(format!("{}/auth/login", base_url)) + .json(&LoginRequest { email, password }) + .send() + .await?; + + let login_response: LoginResponse = ensure_success(response).await?.json().await?; + let auth_pass = build_auth_pass(login_response.access_token, login_response.expires_in)?; + lock_w!(FDOLL).auth.auth_pass = Some(auth_pass.clone()); + save_auth_pass(&auth_pass)?; + Ok(auth_pass) +} + +pub async fn register( + email: &str, + password: &str, + name: Option<&str>, + username: Option<&str>, +) -> Result { + let (base_url, http_client) = auth_http_context()?; + let response = http_client + .post(format!("{}/auth/register", base_url)) + .json(&RegisterRequest { + email, + password, + name, + username, + }) + .send() + .await?; + + let register_response: RegisterResponse = ensure_success(response).await?.json().await?; + Ok(register_response.id) +} + +pub async fn change_password( + current_password: &str, + new_password: &str, +) -> Result<(), AuthError> { + let (base_url, http_client) = auth_http_context()?; + let response = with_auth(http_client.post(format!("{}/auth/change-password", base_url)).json( + &ChangePasswordRequest { + current_password, + new_password, + }, + )) + .await + .send() + .await?; + + ensure_success(response).await?; + Ok(()) +} + +pub async fn reset_password(old_password: &str, new_password: &str) -> Result<(), AuthError> { + let (base_url, http_client) = auth_http_context()?; + let response = with_auth(http_client.post(format!("{}/auth/reset-password", base_url)).json( + &ResetPasswordRequest { + old_password, + new_password, + }, + )) + .await + .send() + .await?; + + ensure_success(response).await?; + Ok(()) +} + +pub async fn refresh_token(access_token: &str) -> Result { + let (base_url, http_client) = auth_http_context()?; + let response = http_client + .post(format!("{}/auth/refresh", base_url)) + .header("Authorization", format!("Bearer {}", access_token)) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + error!("Token refresh failed with status {}: {}", status, error_text); + return Err(AuthError::RefreshFailed); + } + + let refresh_response: LoginResponse = response.json().await?; + let auth_pass = build_auth_pass(refresh_response.access_token, refresh_response.expires_in)?; + lock_w!(FDOLL).auth.auth_pass = Some(auth_pass.clone()); + save_auth_pass(&auth_pass)?; + Ok(auth_pass) +} diff --git a/src-tauri/src/services/auth/mod.rs b/src-tauri/src/services/auth/mod.rs new file mode 100644 index 0000000..de1e09e --- /dev/null +++ b/src-tauri/src/services/auth/mod.rs @@ -0,0 +1,7 @@ +mod api; +mod session; +mod storage; + +pub use api::{change_password, refresh_token, register, reset_password, with_auth}; +pub use session::{get_access_token, get_session_token, login_and_init_session, logout_and_restart}; +pub use storage::{clear_auth_pass, load_auth_pass, AuthPass}; diff --git a/src-tauri/src/services/auth/session.rs b/src-tauri/src/services/auth/session.rs new file mode 100644 index 0000000..6d29554 --- /dev/null +++ b/src-tauri/src/services/auth/session.rs @@ -0,0 +1,41 @@ +use tracing::info; + +use crate::get_app_handle; +use crate::init::lifecycle::construct_user_session; +use crate::services::scene::close_splash_window; +use crate::services::welcome::close_welcome_window; +use crate::state::auth::get_auth_pass_with_refresh; +use crate::{lock_w, state::FDOLL}; + +use super::storage::{clear_auth_pass, AuthError, AuthPass}; + +pub async fn get_session_token() -> Option { + get_auth_pass_with_refresh().await +} + +pub async fn get_access_token() -> Option { + get_session_token().await.map(|pass| pass.access_token) +} + +pub fn logout() -> Result<(), AuthError> { + info!("Logging out user"); + lock_w!(FDOLL).auth.auth_pass = None; + clear_auth_pass()?; + Ok(()) +} + +pub async fn logout_and_restart() -> Result<(), AuthError> { + logout()?; + let app_handle = get_app_handle(); + app_handle.restart(); +} + +pub async fn login_and_init_session(email: &str, password: &str) -> Result<(), AuthError> { + super::api::login(email, password).await?; + close_welcome_window(); + tauri::async_runtime::spawn(async { + construct_user_session().await; + close_splash_window(); + }); + Ok(()) +} diff --git a/src-tauri/src/services/auth/storage.rs b/src-tauri/src/services/auth/storage.rs new file mode 100644 index 0000000..6cb5453 --- /dev/null +++ b/src-tauri/src/services/auth/storage.rs @@ -0,0 +1,198 @@ +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use flate2::{read::GzDecoder, write::GzEncoder, Compression}; +use keyring::Entry; +use serde::{Deserialize, Serialize}; +use std::io::{Read, Write}; +use std::time::{SystemTime, UNIX_EPOCH}; +use thiserror::Error; +use tracing::{error, info}; + +const SERVICE_NAME: &str = "friendolls"; + +#[derive(Debug, Error)] +pub enum AuthError { + #[error("Keyring error: {0}")] + KeyringError(#[from] keyring::Error), + + #[error("Network error: {0}")] + NetworkError(#[from] reqwest::Error), + + #[error("JSON serialization error: {0}")] + SerializationError(#[from] serde_json::Error), + + #[error("Invalid app configuration")] + InvalidConfig, + + #[error("Failed to refresh token")] + RefreshFailed, + + #[error("Request failed: {0}")] + RequestFailed(String), + + #[error("IO error: {0}")] + IoError(#[from] std::io::Error), +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AuthPass { + pub access_token: String, + pub expires_in: u64, + pub issued_at: Option, +} + +pub(crate) fn build_auth_pass( + access_token: String, + expires_in: u64, +) -> Result { + let issued_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| AuthError::RefreshFailed)? + .as_secs(); + Ok(AuthPass { + access_token, + expires_in, + issued_at: Some(issued_at), + }) +} + +pub fn save_auth_pass(auth_pass: &AuthPass) -> Result<(), AuthError> { + let json = serde_json::to_string(auth_pass)?; + let mut encoder = GzEncoder::new(Vec::new(), Compression::best()); + encoder + .write_all(json.as_bytes()) + .map_err(|e| AuthError::SerializationError(serde_json::Error::io(e)))?; + let compressed = encoder + .finish() + .map_err(|e| AuthError::SerializationError(serde_json::Error::io(e)))?; + let encoded = URL_SAFE_NO_PAD.encode(&compressed); + + #[cfg(target_os = "windows")] + { + const CHUNK_SIZE: usize = 1200; + let chunks: Vec<&str> = encoded + .as_bytes() + .chunks(CHUNK_SIZE) + .map(|chunk| std::str::from_utf8(chunk).expect("base64 chunk is valid utf-8")) + .collect(); + + let count_entry = Entry::new(SERVICE_NAME, "auth_pass_count")?; + count_entry.set_password(&chunks.len().to_string())?; + + for (i, chunk) in chunks.iter().enumerate() { + let entry = Entry::new(SERVICE_NAME, &format!("auth_pass_{}", i))?; + entry.set_password(chunk)?; + } + } + + #[cfg(not(target_os = "windows"))] + { + let entry = Entry::new(SERVICE_NAME, "auth_pass")?; + entry.set_password(&encoded)?; + } + + Ok(()) +} + +pub fn load_auth_pass() -> Result, AuthError> { + #[cfg(target_os = "windows")] + let encoded = { + let count_entry = Entry::new(SERVICE_NAME, "auth_pass_count")?; + let chunk_count = match count_entry.get_password() { + Ok(count_str) => match count_str.parse::() { + Ok(count) => count, + Err(_) => { + error!("Invalid chunk count in keyring"); + return Ok(None); + } + }, + Err(keyring::Error::NoEntry) => { + info!("No auth pass found in keyring"); + return Ok(None); + } + Err(e) => { + error!("Failed to load chunk count from keyring"); + return Err(AuthError::KeyringError(e)); + } + }; + + let mut encoded = String::new(); + for i in 0..chunk_count { + let entry = Entry::new(SERVICE_NAME, &format!("auth_pass_{}", i))?; + match entry.get_password() { + Ok(chunk) => encoded.push_str(&chunk), + Err(e) => { + error!("Failed to load chunk {} from keyring", i); + return Err(AuthError::KeyringError(e)); + } + } + } + encoded + }; + + #[cfg(not(target_os = "windows"))] + let encoded = { + let entry = Entry::new(SERVICE_NAME, "auth_pass")?; + match entry.get_password() { + Ok(pass) => pass, + Err(keyring::Error::NoEntry) => { + info!("No auth pass found in keyring"); + return Ok(None); + } + Err(e) => { + error!("Failed to load auth pass from keyring"); + return Err(AuthError::KeyringError(e)); + } + } + }; + + let compressed = match URL_SAFE_NO_PAD.decode(&encoded) { + Ok(c) => c, + Err(e) => { + error!("Failed to base64 decode auth pass from keyring: {}", e); + return Ok(None); + } + }; + + let mut decoder = GzDecoder::new(&compressed[..]); + let mut json = String::new(); + if let Err(e) = decoder.read_to_string(&mut json) { + error!("Failed to decompress auth pass from keyring: {}", e); + return Ok(None); + } + + let auth_pass: AuthPass = match serde_json::from_str(&json) { + Ok(v) => v, + Err(_) => { + error!("Failed to decode auth pass from keyring"); + return Ok(None); + } + }; + + Ok(Some(auth_pass)) +} + +pub fn clear_auth_pass() -> Result<(), AuthError> { + #[cfg(target_os = "windows")] + { + let count_entry = Entry::new(SERVICE_NAME, "auth_pass_count")?; + let chunk_count = match count_entry.get_password() { + Ok(count_str) => count_str.parse::().unwrap_or(0), + Err(_) => 0, + }; + + for i in 0..chunk_count { + let entry = Entry::new(SERVICE_NAME, &format!("auth_pass_{}", i))?; + let _ = entry.delete_credential(); + } + + let _ = count_entry.delete_credential(); + } + + #[cfg(not(target_os = "windows"))] + { + let entry = Entry::new(SERVICE_NAME, "auth_pass")?; + let _ = entry.delete_credential(); + } + + Ok(()) +} diff --git a/src-tauri/src/services/client_config_manager/mod.rs b/src-tauri/src/services/client_config_manager/mod.rs new file mode 100644 index 0000000..d61f0a0 --- /dev/null +++ b/src-tauri/src/services/client_config_manager/mod.rs @@ -0,0 +1,32 @@ +mod store; +mod window; + +use serde::{Deserialize, Serialize}; +use specta::Type; +use thiserror::Error; + +pub use store::{load_app_config, save_app_config}; +pub use window::open_config_manager_window; + +#[derive(Default, Serialize, Deserialize, Clone, Debug, Type)] +pub struct AppConfig { + pub api_base_url: Option, +} + +#[derive(Debug, Error)] +pub enum ClientConfigError { + #[error("failed to resolve app config dir: {0}")] + ResolvePath(tauri::Error), + #[error("io error: {0}")] + Io(#[from] std::io::Error), + #[error("failed to parse client config: {0}")] + Parse(#[from] serde_json::Error), + #[error("failed to run on main thread: {0}")] + Dispatch(#[from] tauri::Error), + #[error("failed to build client config manager window: {0}")] + Window(tauri::Error), + #[error("failed to show client config manager window: {0}")] + ShowWindow(tauri::Error), +} + +pub static CLIENT_CONFIG_MANAGER_WINDOW_LABEL: &str = "client_config_manager"; diff --git a/src-tauri/src/services/client_config_manager.rs b/src-tauri/src/services/client_config_manager/store.rs similarity index 53% rename from src-tauri/src/services/client_config_manager.rs rename to src-tauri/src/services/client_config_manager/store.rs index 93638c6..f2fe0d5 100644 --- a/src-tauri/src/services/client_config_manager.rs +++ b/src-tauri/src/services/client_config_manager/store.rs @@ -1,36 +1,13 @@ use std::{fs, path::PathBuf}; -use serde::{Deserialize, Serialize}; -use specta::Type; use tauri::Manager; -use thiserror::Error; -use tracing::{error, warn}; +use tracing::warn; use url::Url; use crate::get_app_handle; -#[derive(Default, Serialize, Deserialize, Clone, Debug, Type)] -pub struct AppConfig { - pub api_base_url: Option, -} +use super::{AppConfig, ClientConfigError}; -#[derive(Debug, Error)] -pub enum ClientConfigError { - #[error("failed to resolve app config dir: {0}")] - ResolvePath(tauri::Error), - #[error("io error: {0}")] - Io(#[from] std::io::Error), - #[error("failed to parse client config: {0}")] - Parse(#[from] serde_json::Error), - #[error("failed to run on main thread: {0}")] - Dispatch(#[from] tauri::Error), - #[error("failed to build client config manager window: {0}")] - Window(tauri::Error), - #[error("failed to show client config manager window: {0}")] - ShowWindow(tauri::Error), -} - -pub static CLIENT_CONFIG_MANAGER_WINDOW_LABEL: &str = "client_config_manager"; const CONFIG_FILENAME: &str = "client_config.json"; const DEFAULT_API_BASE_URL: &str = "https://api.fdolls.adamcv.com"; @@ -126,48 +103,3 @@ pub fn save_app_config(config: AppConfig) -> Result Result<(), ClientConfigError> { - let app_handle = get_app_handle(); - let existing_webview_window = app_handle.get_window(CLIENT_CONFIG_MANAGER_WINDOW_LABEL); - - if let Some(window) = existing_webview_window { - if let Err(e) = window.show() { - error!("Failed to show client config manager window: {e}"); - return Err(ClientConfigError::ShowWindow(e)); - } - if let Err(e) = window.set_focus() { - error!("Failed to focus client config manager window: {e}"); - } - return Ok(()); - } - - match tauri::WebviewWindowBuilder::new( - app_handle, - CLIENT_CONFIG_MANAGER_WINDOW_LABEL, - tauri::WebviewUrl::App("/client-config-manager".into()), - ) - .title("Advanced Configuration") - .inner_size(300.0, 420.0) - .resizable(false) - .maximizable(false) - .visible(false) - .build() - { - Ok(window) => { - if let Err(e) = window.show() { - error!("Failed to show client config manager window: {}", e); - return Err(ClientConfigError::ShowWindow(e)); - } - if let Err(e) = window.set_focus() { - error!("Failed to focus client config manager window: {e}"); - } - Ok(()) - } - Err(e) => { - error!("Failed to build client config manager window: {}", e); - Err(ClientConfigError::Window(e)) - } - } -} diff --git a/src-tauri/src/services/client_config_manager/window.rs b/src-tauri/src/services/client_config_manager/window.rs new file mode 100644 index 0000000..116801e --- /dev/null +++ b/src-tauri/src/services/client_config_manager/window.rs @@ -0,0 +1,51 @@ +use tauri::Manager; +use tracing::error; + +use crate::get_app_handle; + +use super::{ClientConfigError, CLIENT_CONFIG_MANAGER_WINDOW_LABEL}; + +#[tauri::command] +pub fn open_config_manager_window() -> Result<(), ClientConfigError> { + let app_handle = get_app_handle(); + let existing_webview_window = app_handle.get_window(CLIENT_CONFIG_MANAGER_WINDOW_LABEL); + + if let Some(window) = existing_webview_window { + if let Err(e) = window.show() { + error!("Failed to show client config manager window: {e}"); + return Err(ClientConfigError::ShowWindow(e)); + } + if let Err(e) = window.set_focus() { + error!("Failed to focus client config manager window: {e}"); + } + return Ok(()); + } + + match tauri::WebviewWindowBuilder::new( + app_handle, + CLIENT_CONFIG_MANAGER_WINDOW_LABEL, + tauri::WebviewUrl::App("/client-config-manager".into()), + ) + .title("Advanced Configuration") + .inner_size(300.0, 420.0) + .resizable(false) + .maximizable(false) + .visible(false) + .build() + { + Ok(window) => { + if let Err(e) = window.show() { + error!("Failed to show client config manager window: {}", e); + return Err(ClientConfigError::ShowWindow(e)); + } + if let Err(e) = window.set_focus() { + error!("Failed to focus client config manager window: {e}"); + } + Ok(()) + } + Err(e) => { + error!("Failed to build client config manager window: {}", e); + Err(ClientConfigError::Window(e)) + } + } +} diff --git a/src-tauri/src/services/scene.rs b/src-tauri/src/services/scene.rs deleted file mode 100644 index a4adbcc..0000000 --- a/src-tauri/src/services/scene.rs +++ /dev/null @@ -1,353 +0,0 @@ -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use std::thread; - -use device_query::{DeviceQuery, DeviceState, Keycode}; -use once_cell::sync::OnceCell; -use tauri::Manager; -use tauri_plugin_positioner::WindowExt; -use tauri_specta::Event as _; -use tracing::{error, info, warn}; - -use crate::{get_app_handle, services::app_events::SceneInteractiveChanged}; - -pub static SCENE_WINDOW_LABEL: &str = "scene"; -pub static SPLASH_WINDOW_LABEL: &str = "splash"; - -static SCENE_INTERACTIVE_STATE: OnceCell> = OnceCell::new(); -static MODIFIER_LISTENER_INIT: OnceCell<()> = OnceCell::new(); - -// New: Track which pets have open menus -static OPEN_PET_MENUS: OnceCell>>> = - OnceCell::new(); - -fn get_open_pet_menus() -> Arc>> { - OPEN_PET_MENUS - .get_or_init(|| Arc::new(std::sync::Mutex::new(std::collections::HashSet::new()))) - .clone() -} - -fn scene_interactive_state() -> Arc { - SCENE_INTERACTIVE_STATE - .get_or_init(|| Arc::new(AtomicBool::new(false))) - .clone() -} - -pub fn update_scene_interactive(interactive: bool, should_click: bool) { - let app_handle = get_app_handle(); - scene_interactive_state().store(interactive, Ordering::SeqCst); - - // If we are forcing interactive to false (e.g. background click), clear any open menus - // This prevents the loop from immediately re-enabling it if the frontend hasn't updated yet - if !interactive { - if let Ok(mut menus) = get_open_pet_menus().lock() { - menus.clear(); - } - } - - if let Some(window) = app_handle.get_window(SCENE_WINDOW_LABEL) { - if let Err(e) = window.set_ignore_cursor_events(!interactive) { - error!("Failed to toggle scene cursor events: {}", e); - } - - if should_click { - // Get cursor position on screen and use Enigo to click - if let Some(pos) = crate::services::cursor::get_latest_cursor_position() { - use enigo::{Button, Direction, Enigo, Mouse, Settings}; - // Initialize Enigo with default settings, handling potential failure - match Enigo::new(&Settings::default()) { - Ok(mut enigo) => { - // Perform a click (Press and Release) - // We ignore the result of the click action itself for now as it usually succeeds if init did - let _ = enigo.button(Button::Left, Direction::Click); - info!("Simulated click at ({}, {})", pos.x, pos.y); - } - Err(e) => { - error!("Failed to initialize Enigo for clicking: {}", e); - } - } - } else { - warn!("Cannot click: No cursor position available yet"); - } - } - - if let Err(e) = SceneInteractiveChanged(interactive).emit(&window) { - error!("Failed to emit scene interactive event: {}", e); - } - } else { - warn!("Scene window not available for interactive update"); - } -} - -#[tauri::command] -#[specta::specta] -pub fn set_scene_interactive(interactive: bool, should_click: bool) { - update_scene_interactive(interactive, should_click); -} - -#[tauri::command] -#[specta::specta] -pub fn get_scene_interactive() -> Result { - Ok(scene_interactive_state().load(Ordering::SeqCst)) -} - -#[tauri::command] -#[specta::specta] -pub fn set_pet_menu_state(id: String, open: bool) { - let menus_arc = get_open_pet_menus(); - let should_update = { - if let Ok(mut menus) = menus_arc.lock() { - if open { - menus.insert(id); - get_app_handle() - .get_window(SCENE_WINDOW_LABEL) - .expect("Scene window should be present") - .set_focus() - .expect("Scene window should be focused"); - } else { - menus.remove(&id); - } - !menus.is_empty() - } else { - false - } - }; - - // After updating state, re-evaluate interactivity immediately - // We don't have direct access to key state here easily without recalculating everything, - // but the loop will pick it up shortly. - // HOWEVER, if we just closed the last menu and keys aren't held, we might want to ensure it closes fast. - // For now, let the loop handle it to avoid race conditions with key states. - // But if we just OPENED a menu, we definitely want to ensure interactive is TRUE. - if should_update { - update_scene_interactive(true, false); - } -} - -#[cfg(target_os = "macos")] -#[link(name = "ApplicationServices", kind = "framework")] -extern "C" { - fn AXIsProcessTrusted() -> bool; -} - -fn start_scene_modifier_listener() { - MODIFIER_LISTENER_INIT.get_or_init(|| { - let state = scene_interactive_state(); - update_scene_interactive(false, false); - - let app_handle = get_app_handle().clone(); - - #[cfg(target_os = "macos")] - unsafe { - info!("Accessibility status: {}", AXIsProcessTrusted()); - if !AXIsProcessTrusted() { - // Warning only - polling might work without explicit permissions for just key state in some contexts, - // or we just want to avoid the crash. We'll show the dialog but not return early if we want to try anyway. - // However, usually global key monitoring requires it. - // Let's show the dialog but NOT return, to try polling. - // Or better, let's keep the return if we think it won't work at all, - // but since the crash was the main issue, let's try to proceed safely. - // For now, I will keep the dialog and the return to encourage users to enable it, - // as it's likely needed for global input monitoring. - - // On second thought, let's keep the return to be safe and clear to the user. - error!("Accessibility permissions not granted. Global modifier listener will NOT start."); - - use tauri_plugin_dialog::DialogExt; - use tauri_plugin_dialog::MessageDialogBuilder; - use tauri_plugin_dialog::MessageDialogKind; - - MessageDialogBuilder::new( - app_handle.dialog().clone(), - "Missing Permissions", - "Friendolls needs Accessibility permissions to detect the Alt key for interactivity. Please grant permissions in System Settings -> Privacy & Security -> Accessibility and restart the app.", - ) - .kind(MessageDialogKind::Warning) - .show(|_| {}); - - return; - } - } - - // Spawn a thread for polling key state - thread::spawn(move || { - let device_state = DeviceState::new(); - let mut last_interactive = false; - - loop { - let keys = device_state.get_keys(); - // Check for Alt key (Option on Mac) - let keys_interactive = (keys.contains(&Keycode::LAlt) || keys.contains(&Keycode::RAlt)) || keys.contains(&Keycode::Command); - - // Check if any pet menus are open - let menus_open = { - if let Ok(menus) = get_open_pet_menus().lock() { - !menus.is_empty() - } else { - false - } - }; - - let interactive = keys_interactive || menus_open; - - if interactive != last_interactive { - // State changed - info!("Interactive state changed to: {}", interactive); - let previous = state.swap(interactive, Ordering::SeqCst); - if previous != interactive { - update_scene_interactive(interactive, false); - } - last_interactive = interactive; - } - - // Poll every 100ms - thread::sleep(std::time::Duration::from_millis(100)); - } - }); - }); -} - -pub fn overlay_fullscreen(window: &tauri::Window) -> Result<(), tauri::Error> { - // Get the primary monitor - let monitor = get_app_handle().primary_monitor()?.unwrap(); - let monitor_size = monitor.size(); - - // Fullscreen the window by expanding the window to match monitor size then move it to the top-left corner - // This forces the window to fit under the notch that exists on MacBooks with a notch - window.set_size(tauri::PhysicalSize { - width: monitor_size.width, - height: monitor_size.height, - })?; - - window.set_position(tauri::PhysicalPosition { x: 0, y: 0 })?; - - Ok(()) -} - -pub fn open_splash_window() { - let app_handle = get_app_handle(); - let existing_webview_window = app_handle.get_window(SPLASH_WINDOW_LABEL); - - if let Some(window) = existing_webview_window { - window.show().unwrap(); - return; - } - - info!("Starting splash window creation..."); - let webview_window = match tauri::WebviewWindowBuilder::new( - app_handle, - SPLASH_WINDOW_LABEL, - tauri::WebviewUrl::App("/splash".into()), - ) - .title("Friendolls Splash") - .inner_size(600.0, 300.0) - .resizable(false) - .decorations(false) - .transparent(true) - .shadow(false) - .visible(false) // Show it after centering - .skip_taskbar(true) - .always_on_top(true) - .build() - { - Ok(window) => { - info!("Splash window builder succeeded"); - window - } - Err(e) => { - error!("Failed to build splash window: {}", e); - return; - } - }; - - if let Err(e) = webview_window.move_window(tauri_plugin_positioner::Position::Center) { - error!("Failed to move splash window to center: {}", e); - // Continue anyway - } - - if let Err(e) = webview_window.show() { - error!("Failed to show splash window: {}", e); - } - - info!("Splash window initialized successfully."); -} - -pub fn close_splash_window() { - let app_handle = get_app_handle(); - if let Some(window) = app_handle.get_window(SPLASH_WINDOW_LABEL) { - if let Err(e) = window.close() { - error!("Failed to close splash window: {}", e); - } else { - info!("Splash window closed"); - } - } -} - -pub fn open_scene_window() { - let app_handle = get_app_handle(); - let existing_webview_window = app_handle.get_window(SCENE_WINDOW_LABEL); - - if let Some(window) = existing_webview_window { - window.show().unwrap(); - return; - } - - info!("Starting scene creation..."); - let webview_window = match tauri::WebviewWindowBuilder::new( - app_handle, - SCENE_WINDOW_LABEL, - tauri::WebviewUrl::App("/scene".into()), - ) - .title("Friendolls Scene") - .inner_size(600.0, 500.0) - .resizable(false) - .decorations(false) - .transparent(true) - .shadow(false) - .visible(true) - .skip_taskbar(true) - .always_on_top(true) - .visible_on_all_workspaces(true) - .build() - { - Ok(window) => { - info!("Scene window builder succeeded"); - window - } - Err(e) => { - error!("Failed to build scene window: {}", e); - return; - } - }; - - if let Err(e) = webview_window.move_window(tauri_plugin_positioner::Position::Center) { - error!("Failed to move scene window to center: {}", e); - return; - } - - let window = match get_app_handle().get_window(webview_window.label()) { - Some(window) => window, - None => { - error!("Failed to get scene window after creation"); - return; - } - }; - - if let Err(e) = overlay_fullscreen(&window) { - error!("Failed to set overlay fullscreen: {}", e); - return; - } - - if let Err(e) = window.set_ignore_cursor_events(true) { - error!("Failed to set ignore cursor events: {}", e); - return; - } - - // Start global modifier listener once scene window exists - start_scene_modifier_listener(); - - #[cfg(debug_assertions)] - webview_window.open_devtools(); - - info!("Scene window initialized successfully."); -} diff --git a/src-tauri/src/services/scene/interactivity.rs b/src-tauri/src/services/scene/interactivity.rs new file mode 100644 index 0000000..a3cd671 --- /dev/null +++ b/src-tauri/src/services/scene/interactivity.rs @@ -0,0 +1,182 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread; + +use device_query::{DeviceQuery, DeviceState, Keycode}; +use once_cell::sync::OnceCell; +use tauri::Manager; +use tauri_specta::Event as _; +use tracing::{error, info, warn}; + +use crate::{get_app_handle, services::app_events::SceneInteractiveChanged}; + +use super::windows::SCENE_WINDOW_LABEL; + +static SCENE_INTERACTIVE_STATE: OnceCell> = OnceCell::new(); +static MODIFIER_LISTENER_INIT: OnceCell<()> = OnceCell::new(); +static OPEN_PET_MENUS: OnceCell>>> = + OnceCell::new(); + +fn get_open_pet_menus() -> Arc>> { + OPEN_PET_MENUS + .get_or_init(|| Arc::new(std::sync::Mutex::new(std::collections::HashSet::new()))) + .clone() +} + +fn scene_interactive_state() -> Arc { + SCENE_INTERACTIVE_STATE + .get_or_init(|| Arc::new(AtomicBool::new(false))) + .clone() +} + +pub(crate) fn start_scene_modifier_listener() { + MODIFIER_LISTENER_INIT.get_or_init(|| { + let state = scene_interactive_state(); + update_scene_interactive(false, false); + + let app_handle = get_app_handle().clone(); + + #[cfg(target_os = "macos")] + unsafe { + info!("Accessibility status: {}", AXIsProcessTrusted()); + if !AXIsProcessTrusted() { + error!( + "Accessibility permissions not granted. Global modifier listener will NOT start." + ); + + use tauri_plugin_dialog::DialogExt; + use tauri_plugin_dialog::MessageDialogBuilder; + use tauri_plugin_dialog::MessageDialogKind; + + MessageDialogBuilder::new( + app_handle.dialog().clone(), + "Missing Permissions", + "Friendolls needs Accessibility permissions to detect the Alt key for interactivity. Please grant permissions in System Settings -> Privacy & Security -> Accessibility and restart the app.", + ) + .kind(MessageDialogKind::Warning) + .show(|_| {}); + + return; + } + } + + thread::spawn(move || { + let device_state = DeviceState::new(); + let mut last_interactive = false; + + loop { + let keys = device_state.get_keys(); + let keys_interactive = + (keys.contains(&Keycode::LAlt) || keys.contains(&Keycode::RAlt)) + || keys.contains(&Keycode::Command); + + let menus_open = { + if let Ok(menus) = get_open_pet_menus().lock() { + !menus.is_empty() + } else { + false + } + }; + + let interactive = keys_interactive || menus_open; + + if interactive != last_interactive { + info!("Interactive state changed to: {}", interactive); + let previous = state.swap(interactive, Ordering::SeqCst); + if previous != interactive { + update_scene_interactive(interactive, false); + } + last_interactive = interactive; + } + + thread::sleep(std::time::Duration::from_millis(100)); + } + }); + }); +} + +pub(crate) fn update_scene_interactive(interactive: bool, should_click: bool) { + let app_handle = get_app_handle(); + scene_interactive_state().store(interactive, Ordering::SeqCst); + + if !interactive { + if let Ok(mut menus) = get_open_pet_menus().lock() { + menus.clear(); + } + } + + if let Some(window) = app_handle.get_window(SCENE_WINDOW_LABEL) { + if let Err(e) = window.set_ignore_cursor_events(!interactive) { + error!("Failed to toggle scene cursor events: {}", e); + } + + if should_click { + if let Some(pos) = crate::services::cursor::get_latest_cursor_position() { + use enigo::{Button, Direction, Enigo, Mouse, Settings}; + + match Enigo::new(&Settings::default()) { + Ok(mut enigo) => { + let _ = enigo.button(Button::Left, Direction::Click); + info!("Simulated click at ({}, {})", pos.x, pos.y); + } + Err(e) => { + error!("Failed to initialize Enigo for clicking: {}", e); + } + } + } else { + warn!("Cannot click: No cursor position available yet"); + } + } + + if let Err(e) = SceneInteractiveChanged(interactive).emit(&window) { + error!("Failed to emit scene interactive event: {}", e); + } + } else { + warn!("Scene window not available for interactive update"); + } +} + +#[tauri::command] +#[specta::specta] +pub fn set_scene_interactive(interactive: bool, should_click: bool) { + update_scene_interactive(interactive, should_click); +} + +#[tauri::command] +#[specta::specta] +pub fn get_scene_interactive() -> Result { + Ok(scene_interactive_state().load(Ordering::SeqCst)) +} + +#[tauri::command] +#[specta::specta] +pub fn set_pet_menu_state(id: String, open: bool) { + let menus_arc = get_open_pet_menus(); + let should_update = { + if let Ok(mut menus) = menus_arc.lock() { + if open { + menus.insert(id); + get_app_handle() + .get_window(SCENE_WINDOW_LABEL) + .expect("Scene window should be present") + .set_focus() + .expect("Scene window should be focused"); + } else { + menus.remove(&id); + } + !menus.is_empty() + } else { + false + } + }; + + if should_update { + update_scene_interactive(true, false); + } +} + +#[cfg(target_os = "macos")] +#[link(name = "ApplicationServices", kind = "framework")] +extern "C" { + fn AXIsProcessTrusted() -> bool; +} diff --git a/src-tauri/src/services/scene/mod.rs b/src-tauri/src/services/scene/mod.rs new file mode 100644 index 0000000..e30468b --- /dev/null +++ b/src-tauri/src/services/scene/mod.rs @@ -0,0 +1,5 @@ +mod interactivity; +mod windows; + +pub use interactivity::{get_scene_interactive, set_pet_menu_state, set_scene_interactive}; +pub use windows::{close_splash_window, open_scene_window, open_splash_window}; diff --git a/src-tauri/src/services/scene/windows.rs b/src-tauri/src/services/scene/windows.rs new file mode 100644 index 0000000..a64ca5e --- /dev/null +++ b/src-tauri/src/services/scene/windows.rs @@ -0,0 +1,150 @@ +use tauri::Manager; +use tauri_plugin_positioner::WindowExt; +use tracing::{error, info}; + +use crate::get_app_handle; + +use super::interactivity::start_scene_modifier_listener; + +pub static SCENE_WINDOW_LABEL: &str = "scene"; +pub static SPLASH_WINDOW_LABEL: &str = "splash"; + +pub fn overlay_fullscreen(window: &tauri::Window) -> Result<(), tauri::Error> { + let monitor = get_app_handle().primary_monitor()?.unwrap(); + let monitor_size = monitor.size(); + + window.set_size(tauri::PhysicalSize { + width: monitor_size.width, + height: monitor_size.height, + })?; + + window.set_position(tauri::PhysicalPosition { x: 0, y: 0 })?; + + Ok(()) +} + +pub fn open_splash_window() { + let app_handle = get_app_handle(); + let existing_webview_window = app_handle.get_window(SPLASH_WINDOW_LABEL); + + if let Some(window) = existing_webview_window { + window.show().unwrap(); + return; + } + + info!("Starting splash window creation..."); + let webview_window = match tauri::WebviewWindowBuilder::new( + app_handle, + SPLASH_WINDOW_LABEL, + tauri::WebviewUrl::App("/splash".into()), + ) + .title("Friendolls Splash") + .inner_size(600.0, 300.0) + .resizable(false) + .decorations(false) + .transparent(true) + .shadow(false) + .visible(false) + .skip_taskbar(true) + .always_on_top(true) + .build() + { + Ok(window) => { + info!("Splash window builder succeeded"); + window + } + Err(e) => { + error!("Failed to build splash window: {}", e); + return; + } + }; + + if let Err(e) = webview_window.move_window(tauri_plugin_positioner::Position::Center) { + error!("Failed to move splash window to center: {}", e); + } + + if let Err(e) = webview_window.show() { + error!("Failed to show splash window: {}", e); + } + + info!("Splash window initialized successfully."); +} + +pub fn close_splash_window() { + let app_handle = get_app_handle(); + if let Some(window) = app_handle.get_window(SPLASH_WINDOW_LABEL) { + if let Err(e) = window.close() { + error!("Failed to close splash window: {}", e); + } else { + info!("Splash window closed"); + } + } +} + +pub fn open_scene_window() { + let app_handle = get_app_handle(); + let existing_webview_window = app_handle.get_window(SCENE_WINDOW_LABEL); + + if let Some(window) = existing_webview_window { + window.show().unwrap(); + return; + } + + info!("Starting scene creation..."); + let webview_window = match tauri::WebviewWindowBuilder::new( + app_handle, + SCENE_WINDOW_LABEL, + tauri::WebviewUrl::App("/scene".into()), + ) + .title("Friendolls Scene") + .inner_size(600.0, 500.0) + .resizable(false) + .decorations(false) + .transparent(true) + .shadow(false) + .visible(true) + .skip_taskbar(true) + .always_on_top(true) + .visible_on_all_workspaces(true) + .build() + { + Ok(window) => { + info!("Scene window builder succeeded"); + window + } + Err(e) => { + error!("Failed to build scene window: {}", e); + return; + } + }; + + if let Err(e) = webview_window.move_window(tauri_plugin_positioner::Position::Center) { + error!("Failed to move scene window to center: {}", e); + return; + } + + let window = match get_app_handle().get_window(webview_window.label()) { + Some(window) => window, + None => { + error!("Failed to get scene window after creation"); + return; + } + }; + + if let Err(e) = overlay_fullscreen(&window) { + error!("Failed to set overlay fullscreen: {}", e); + return; + } + + if let Err(e) = window.set_ignore_cursor_events(true) { + error!("Failed to set ignore cursor events: {}", e); + return; + } + + start_scene_modifier_listener(); + + #[cfg(debug_assertions)] + webview_window.open_devtools(); + + info!("Scene window initialized successfully."); +}