Rust service refactor: auth, scene & client config
This commit is contained in:
@@ -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<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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<AuthPass, AuthError> {
|
|
||||||
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<AuthPass> {
|
|
||||||
get_auth_pass_with_refresh().await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_access_token() -> Option<String> {
|
|
||||||
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<Option<AuthPass>, 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::<usize>() {
|
|
||||||
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::<usize>().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<AuthPass, 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/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<String, 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/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<AuthPass, AuthError> {
|
|
||||||
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(())
|
|
||||||
}
|
|
||||||
182
src-tauri/src/services/auth/api.rs
Normal file
182
src-tauri/src/services/auth/api.rs
Normal file
@@ -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<reqwest::Response, AuthError> {
|
||||||
|
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<AuthPass, AuthError> {
|
||||||
|
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<String, AuthError> {
|
||||||
|
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<AuthPass, AuthError> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
7
src-tauri/src/services/auth/mod.rs
Normal file
7
src-tauri/src/services/auth/mod.rs
Normal file
@@ -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};
|
||||||
41
src-tauri/src/services/auth/session.rs
Normal file
41
src-tauri/src/services/auth/session.rs
Normal file
@@ -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<AuthPass> {
|
||||||
|
get_auth_pass_with_refresh().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_access_token() -> Option<String> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
198
src-tauri/src/services/auth/storage.rs
Normal file
198
src-tauri/src/services/auth/storage.rs
Normal file
@@ -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<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_auth_pass(
|
||||||
|
access_token: String,
|
||||||
|
expires_in: u64,
|
||||||
|
) -> Result<AuthPass, AuthError> {
|
||||||
|
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<Option<AuthPass>, 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::<usize>() {
|
||||||
|
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::<usize>().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(())
|
||||||
|
}
|
||||||
32
src-tauri/src/services/client_config_manager/mod.rs
Normal file
32
src-tauri/src/services/client_config_manager/mod.rs
Normal file
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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";
|
||||||
@@ -1,36 +1,13 @@
|
|||||||
use std::{fs, path::PathBuf};
|
use std::{fs, path::PathBuf};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use specta::Type;
|
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
use thiserror::Error;
|
use tracing::warn;
|
||||||
use tracing::{error, warn};
|
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use crate::get_app_handle;
|
use crate::get_app_handle;
|
||||||
|
|
||||||
#[derive(Default, Serialize, Deserialize, Clone, Debug, Type)]
|
use super::{AppConfig, ClientConfigError};
|
||||||
pub struct AppConfig {
|
|
||||||
pub api_base_url: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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 CONFIG_FILENAME: &str = "client_config.json";
|
||||||
const DEFAULT_API_BASE_URL: &str = "https://api.fdolls.adamcv.com";
|
const DEFAULT_API_BASE_URL: &str = "https://api.fdolls.adamcv.com";
|
||||||
|
|
||||||
@@ -126,48 +103,3 @@ pub fn save_app_config(config: AppConfig) -> Result<AppConfig, ClientConfigError
|
|||||||
|
|
||||||
Ok(sanitized)
|
Ok(sanitized)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
51
src-tauri/src/services/client_config_manager/window.rs
Normal file
51
src-tauri/src/services/client_config_manager/window.rs
Normal file
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Arc<AtomicBool>> = OnceCell::new();
|
|
||||||
static MODIFIER_LISTENER_INIT: OnceCell<()> = OnceCell::new();
|
|
||||||
|
|
||||||
// New: Track which pets have open menus
|
|
||||||
static OPEN_PET_MENUS: OnceCell<Arc<std::sync::Mutex<std::collections::HashSet<String>>>> =
|
|
||||||
OnceCell::new();
|
|
||||||
|
|
||||||
fn get_open_pet_menus() -> Arc<std::sync::Mutex<std::collections::HashSet<String>>> {
|
|
||||||
OPEN_PET_MENUS
|
|
||||||
.get_or_init(|| Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())))
|
|
||||||
.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn scene_interactive_state() -> Arc<AtomicBool> {
|
|
||||||
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<bool, String> {
|
|
||||||
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.");
|
|
||||||
}
|
|
||||||
182
src-tauri/src/services/scene/interactivity.rs
Normal file
182
src-tauri/src/services/scene/interactivity.rs
Normal file
@@ -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<Arc<AtomicBool>> = OnceCell::new();
|
||||||
|
static MODIFIER_LISTENER_INIT: OnceCell<()> = OnceCell::new();
|
||||||
|
static OPEN_PET_MENUS: OnceCell<Arc<std::sync::Mutex<std::collections::HashSet<String>>>> =
|
||||||
|
OnceCell::new();
|
||||||
|
|
||||||
|
fn get_open_pet_menus() -> Arc<std::sync::Mutex<std::collections::HashSet<String>>> {
|
||||||
|
OPEN_PET_MENUS
|
||||||
|
.get_or_init(|| Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())))
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scene_interactive_state() -> Arc<AtomicBool> {
|
||||||
|
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<bool, String> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
5
src-tauri/src/services/scene/mod.rs
Normal file
5
src-tauri/src/services/scene/mod.rs
Normal file
@@ -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};
|
||||||
150
src-tauri/src/services/scene/windows.rs
Normal file
150
src-tauri/src/services/scene/windows.rs
Normal file
@@ -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.");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user