This commit is contained in:
2026-03-20 18:07:16 +01:00
parent 45b0bf00ee
commit 1342123990
25 changed files with 2023 additions and 382 deletions

1217
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,15 +18,17 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri = { version = "2.10.3", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
opusic-sys = "0.5"
tokio = { version = "1.49", features = ["full"] }
opusic-sys = "0.6"
tokio = { version = "1.50", features = ["full"] }
reqwest = { version = "0.13", features = ["json", "rustls"] }
thiserror = "2.0"
figment = "0.10.19"
parking_lot = "0.12"
toml = "0.9"
ssh-key = { version = "0.6", features = ["default", "crypto"] }
base64 = "0.22"
toml = "1.0.7+spec-1.1.0"
ssh-key = { version = "0.7.0-rc.9", features = ["default", "crypto"] }
base64 = "0.22"

247
src-tauri/src/api/client.rs Normal file
View File

@@ -0,0 +1,247 @@
use crate::api::models::*;
use parking_lot::RwLock;
use reqwest::{header, Client, Method, RequestBuilder, Response};
use serde::de::DeserializeOwned;
use serde::Serialize;
use std::sync::Arc;
/// Erreurs possibles lors de l'utilisation du client API.
#[derive(Debug, thiserror::Error, Serialize)]
#[serde(tag = "type", content = "data")]
pub enum ApiError {
#[error("Erreur de requête HTTP: {0}")]
Http(String),
#[error("Erreur de désérialisation: {0}")]
Json(String),
#[error("Réponse d'erreur du serveur: {status} - {body}")]
ServerError { status: u16, body: String },
}
impl From<reqwest::Error> for ApiError {
fn from(err: reqwest::Error) -> Self {
ApiError::Http(err.to_string())
}
}
impl From<serde_json::Error> for ApiError {
fn from(err: serde_json::Error) -> Self {
ApiError::Json(err.to_string())
}
}
pub type ApiResult<T> = Result<T, ApiError>;
/// Un client API générique capable de se connecter à plusieurs serveurs.
/// Il gère l'authentification par token JWT de manière thread-safe.
#[derive(Clone, Debug)]
pub struct ApiClient {
base_url: String,
inner: Client,
token: Arc<RwLock<Option<String>>>,
}
impl ApiClient {
/// Initialise un nouveau client pour un serveur spécifique.
pub fn new(base_url: String) -> Self {
let mut headers = header::HeaderMap::new();
headers.insert(
header::CONTENT_TYPE,
header::HeaderValue::from_static("application/json"),
);
let inner = Client::builder()
.default_headers(headers)
.build()
.unwrap_or_else(|_| Client::new());
Self {
base_url: base_url.trim_end_matches('/').to_string(),
inner,
token: Arc::new(RwLock::new(None)),
}
}
/// Définit le token JWT à utiliser pour les requêtes authentifiées.
pub fn set_token(&self, token: String) {
let mut lock = self.token.write();
*lock = Some(token);
}
/// Récupère le token JWT actuel s'il existe.
pub fn get_token(&self) -> Option<String> {
self.token.read().clone()
}
/// Supprime le token JWT actuel.
pub fn clear_token(&self) {
let mut lock = self.token.write();
*lock = None;
}
/// Construit une requête HTTP avec l'authentification si disponible.
fn build_request(&self, method: Method, path: &str) -> RequestBuilder {
let url = if path.starts_with("http") {
path.to_string()
} else {
format!("{}/{}", self.base_url, path.trim_start_matches('/'))
};
let mut rb = self.inner.request(method, url);
if let Some(token) = self.token.read().as_ref() {
rb = rb.header(header::AUTHORIZATION, format!("Bearer {}", token));
}
rb
}
async fn handle_response<T: DeserializeOwned>(&self, response: Response) -> ApiResult<T> {
let status = response.status();
if status.is_success() {
let data = response.json::<T>().await?;
Ok(data)
} else {
let body = response.text().await.unwrap_or_default();
Err(ApiError::ServerError {
status: status.as_u16(),
body,
})
}
}
pub async fn get<T: DeserializeOwned>(&self, path: &str) -> ApiResult<T> {
let response = self.build_request(Method::GET, path).send().await?;
self.handle_response(response).await
}
pub async fn post<B: Serialize, T: DeserializeOwned>(
&self,
path: &str,
body: &B,
) -> ApiResult<T> {
let response = self
.build_request(Method::POST, path)
.json(body)
.send()
.await?;
self.handle_response(response).await
}
pub async fn post_empty<T: DeserializeOwned>(&self, path: &str) -> ApiResult<T> {
let response = self.build_request(Method::POST, path).send().await?;
self.handle_response(response).await
}
pub async fn put<B: Serialize, T: DeserializeOwned>(
&self,
path: &str,
body: &B,
) -> ApiResult<T> {
let response = self
.build_request(Method::PUT, path)
.json(body)
.send()
.await?;
self.handle_response(response).await
}
pub async fn delete<T: DeserializeOwned>(&self, path: &str) -> ApiResult<T> {
let response = self.build_request(Method::DELETE, path).send().await?;
self.handle_response(response).await
}
// --- ECOSYSTEMS ---
// Auth
pub async fn login(&self, req: &LoginRequest) -> ApiResult<LoginResponse> {
self.post("api/auth/login", req).await
}
pub async fn claim_admin(&self, req: &ClaimAdminRequest) -> ApiResult<()> {
self.post("api/auth/claim-admin", req).await
}
pub async fn ssh_challenge(&self) -> ApiResult<SshChallengeResponse> {
self.post_empty("api/auth/ssh-challenge").await
}
// Server
pub async fn server_list(&self) -> ApiResult<Vec<ServerResponse>> {
self.get("api/server").await
}
pub async fn server_create(&self, req: &CreateServerRequest) -> ApiResult<ServerResponse> {
self.post("api/server", req).await
}
// Category
pub async fn category_list(&self, server_id: Option<&str>) -> ApiResult<Vec<CategoryResponse>> {
let path = if let Some(id) = server_id {
format!("api/category?server_id={}", id)
} else {
"api/category".to_string()
};
self.get(&path).await
}
pub async fn category_create(
&self,
req: &CreateCategoryRequest,
) -> ApiResult<CategoryResponse> {
self.post("api/category", req).await
}
pub async fn category_detail(&self, id: &str) -> ApiResult<CategoryResponse> {
self.get(&format!("api/category/{}", id)).await
}
pub async fn category_update(
&self,
id: &str,
req: &CreateCategoryRequest,
) -> ApiResult<CategoryResponse> {
self.put(&format!("api/category/{}", id), req).await
}
pub async fn category_delete(&self, id: &str) -> ApiResult<()> {
self.delete(&format!("api/category/{}", id)).await
}
// Channel
pub async fn channel_list(&self) -> ApiResult<Vec<ChannelResponse>> {
self.get("api/channel").await
}
pub async fn channel_create(&self, req: &CreateChannelRequest) -> ApiResult<ChannelResponse> {
self.post("api/channel", req).await
}
pub async fn channel_detail(&self, id: &str) -> ApiResult<ChannelResponse> {
self.get(&format!("api/channel/{}", id)).await
}
pub async fn channel_update(
&self,
id: &str,
req: &CreateChannelRequest,
) -> ApiResult<ChannelResponse> {
self.put(&format!("api/channel/{}", id), req).await
}
pub async fn channel_delete(&self, id: &str) -> ApiResult<()> {
self.delete(&format!("api/channel/{}", id)).await
}
// Message
pub async fn message_list(&self) -> ApiResult<Vec<MessageResponse>> {
self.get("api/message").await
}
pub async fn message_create(&self, req: &CreateMessageRequest) -> ApiResult<MessageResponse> {
self.post("api/message", req).await
}
// User
pub async fn user_list(&self) -> ApiResult<Vec<UserResponse>> {
self.get("api/user").await
}
}

View File

@@ -0,0 +1,133 @@
use crate::api::models::*;
use crate::api::ApiResult;
use crate::app::state::AppState;
use tauri::{command, State};
// --- AUTH ---
#[command]
pub async fn api_login(
state: State<'_, AppState>,
base_url: String,
req: LoginRequest,
) -> ApiResult<LoginResponse> {
let client = state.api.get_or_create_client(base_url);
let res = client.login(&req).await?;
client.set_token(res.token.clone());
Ok(res)
}
#[command]
pub async fn api_claim_admin(
state: State<'_, AppState>,
base_url: String,
req: ClaimAdminRequest,
) -> ApiResult<()> {
let client = state.api.get_or_create_client(base_url);
client.claim_admin(&req).await
}
#[command]
pub async fn api_ssh_challenge(
state: State<'_, AppState>,
base_url: String,
) -> ApiResult<SshChallengeResponse> {
let client = state.api.get_or_create_client(base_url);
client.ssh_challenge().await
}
// --- SERVER ---
#[command]
pub async fn api_server_list(
state: State<'_, AppState>,
base_url: String,
) -> ApiResult<Vec<ServerResponse>> {
let client = state.api.get_or_create_client(base_url);
client.server_list().await
}
#[command]
pub async fn api_server_create(
state: State<'_, AppState>,
base_url: String,
req: CreateServerRequest,
) -> ApiResult<ServerResponse> {
let client = state.api.get_or_create_client(base_url);
client.server_create(&req).await
}
// --- CATEGORY ---
#[command]
pub async fn api_category_list(
state: State<'_, AppState>,
base_url: String,
server_id: Option<String>,
) -> ApiResult<Vec<CategoryResponse>> {
let client = state.api.get_or_create_client(base_url);
client.category_list(server_id.as_deref()).await
}
#[command]
pub async fn api_category_create(
state: State<'_, AppState>,
base_url: String,
req: CreateCategoryRequest,
) -> ApiResult<CategoryResponse> {
let client = state.api.get_or_create_client(base_url);
client.category_create(&req).await
}
// --- CHANNEL ---
#[command]
pub async fn api_channel_list(
state: State<'_, AppState>,
base_url: String,
) -> ApiResult<Vec<ChannelResponse>> {
let client = state.api.get_or_create_client(base_url);
client.channel_list().await
}
#[command]
pub async fn api_channel_create(
state: State<'_, AppState>,
base_url: String,
req: CreateChannelRequest,
) -> ApiResult<ChannelResponse> {
let client = state.api.get_or_create_client(base_url);
client.channel_create(&req).await
}
// --- MESSAGE ---
#[command]
pub async fn api_message_list(
state: State<'_, AppState>,
base_url: String,
) -> ApiResult<Vec<MessageResponse>> {
let client = state.api.get_or_create_client(base_url);
client.message_list().await
}
#[command]
pub async fn api_message_create(
state: State<'_, AppState>,
base_url: String,
req: CreateMessageRequest,
) -> ApiResult<MessageResponse> {
let client = state.api.get_or_create_client(base_url);
client.message_create(&req).await
}
// --- USER ---
#[command]
pub async fn api_user_list(
state: State<'_, AppState>,
base_url: String,
) -> ApiResult<Vec<UserResponse>> {
let client = state.api.get_or_create_client(base_url);
client.user_list().await
}

7
src-tauri/src/api/mod.rs Normal file
View File

@@ -0,0 +1,7 @@
pub mod client;
pub mod commands;
pub mod models;
pub mod state;
pub use client::{ApiClient, ApiError, ApiResult};
pub use state::ApiManager;

115
src-tauri/src/api/models.rs Normal file
View File

@@ -0,0 +1,115 @@
use serde::{Deserialize, Serialize};
// --- AUTH ---
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct LoginRequest {
pub username: String,
pub password: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct LoginResponse {
pub token: String,
pub username: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ClaimAdminRequest {
pub token: String,
pub username: String,
pub password: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SshChallengeResponse {
pub challenge: String,
}
// --- SERVER ---
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CreateServerRequest {
pub name: String,
pub password: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct ServerResponse {
pub id: String,
pub name: String,
pub created_at: String,
pub updated_at: String,
}
// --- CATEGORY ---
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CreateCategoryRequest {
pub server_id: String,
pub name: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CategoryResponse {
pub id: String,
pub name: String,
}
// --- CHANNEL ---
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ChannelType {
Text,
Voice,
DM,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CreateChannelRequest {
pub channel_type: ChannelType,
pub category_id: Option<String>,
pub name: Option<String>,
pub position: Option<i32>,
pub server_id: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ChannelResponse {
pub id: String,
pub name: Option<String>,
pub position: i32,
pub channel_type: ChannelType,
pub category_id: Option<String>,
}
// --- MESSAGE ---
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CreateMessageRequest {
pub channel_id: String,
pub content: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MessageResponse {
pub id: String,
pub channel_id: String,
pub author_id: String,
pub content: String,
}
// --- USER ---
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CreateUserRequest {
pub username: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct UserResponse {
pub id: String,
pub username: String,
pub pub_key: String,
}

View File

@@ -0,0 +1,35 @@
use crate::api::client::ApiClient;
use parking_lot::RwLock;
use std::collections::HashMap;
/// Gère plusieurs instances de ApiClient (une par serveur).
#[derive(Debug, Default)]
pub struct ApiManager {
clients: RwLock<HashMap<String, ApiClient>>,
}
impl ApiManager {
pub fn new() -> Self {
Self {
clients: RwLock::new(HashMap::new()),
}
}
/// Récupère ou crée un client pour une base_url donnée.
pub fn get_or_create_client(&self, base_url: String) -> ApiClient {
let mut clients = self.clients.write();
if let Some(client) = clients.get(&base_url) {
client.clone()
} else {
let client = ApiClient::new(base_url.clone());
clients.insert(base_url, client.clone());
client
}
}
/// Supprime un client.
pub fn remove_client(&self, base_url: &str) {
let mut clients = self.clients.write();
clients.remove(base_url);
}
}

View File

@@ -1 +1,2 @@
pub mod ox_speak_app;
pub mod ox_speak_app;
pub mod state;

View File

@@ -1,5 +1,5 @@
use crate::config::ConfigManager;
use tauri::{AppHandle, Manager};
use tauri::AppHandle;
pub struct OxSpeakApp {
tauri_handle: AppHandle,
@@ -7,17 +7,7 @@ pub struct OxSpeakApp {
}
impl OxSpeakApp {
pub async fn new(tauri_handle: AppHandle) -> Self {
// Chargement de la configuration
let config_dir = tauri_handle
.path()
.app_config_dir()
.expect("Failed to get app config dir");
let config = ConfigManager::load(config_dir)
.await
.expect("Failed to load config");
pub async fn new(tauri_handle: AppHandle, config: ConfigManager) -> Self {
Self {
tauri_handle,
config,

View File

@@ -0,0 +1,19 @@
use crate::api::state::ApiManager;
use crate::config::ConfigManager;
use std::sync::OnceLock;
/// État global de l'application.
#[derive(Debug, Default)]
pub struct AppState {
pub api: ApiManager,
pub config: OnceLock<ConfigManager>,
}
impl AppState {
pub fn new() -> Self {
Self {
api: ApiManager::new(),
config: OnceLock::new(),
}
}
}

View File

@@ -0,0 +1,21 @@
use crate::app::state::AppState;
use crate::config::config::ConfigTree;
use tauri::{command, State};
#[command]
pub async fn config_get(state: State<'_, AppState>) -> Result<ConfigTree, String> {
let config = state.config.get().ok_or("Config non initialisée")?;
Ok(config.get_config())
}
#[command]
pub async fn config_update(
state: State<'_, AppState>,
new_config: ConfigTree,
) -> Result<(), String> {
let config = state.config.get().ok_or("Config non initialisée")?;
config
.update_config(new_config)
.await
.map_err(|e| e.to_string())
}

View File

@@ -5,35 +5,37 @@ use ssh_key::PrivateKey;
use std::net::{SocketAddr, ToSocketAddrs};
use std::path::PathBuf;
use std::sync::Arc;
use tauri::{AppHandle, Emitter};
use tokio::fs;
#[derive(Debug, Clone)]
pub struct ConfigManager {
tree_config: Arc<Mutex<ConfigTree>>, // On garanti l'unicité avec un Mutex
path: PathBuf,
app_handle: Option<AppHandle>,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
struct ConfigTree {
pub struct ConfigTree {
pub servers: Vec<ConfigServer>,
pub identities: Vec<IdentityConfig>,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
struct ConfigServer {
pub struct ConfigServer {
pub adresse: String,
pub identity: String,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
#[serde(rename_all = "snake_case")]
enum IdentityMode {
pub enum IdentityMode {
PrivateKeyPath,
PrivateKeyBase64,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
struct IdentityConfig {
pub struct IdentityConfig {
pub id: String,
pub username: String,
pub private_key: String,
@@ -41,7 +43,26 @@ struct IdentityConfig {
}
impl ConfigManager {
pub async fn load(config_dir: PathBuf) -> Result<Self, std::io::Error> {
pub fn get_config(&self) -> ConfigTree {
self.tree_config.lock().clone()
}
pub async fn update_config(&self, new_config: ConfigTree) -> Result<(), std::io::Error> {
{
let mut lock = self.tree_config.lock();
*lock = new_config.clone();
}
self.save().await?;
// Notifier le front-end du changement
if let Some(handle) = &self.app_handle {
let _ = handle.emit("config-changed", new_config);
}
Ok(())
}
pub async fn load(config_dir: PathBuf, app_handle: AppHandle) -> Result<Self, std::io::Error> {
let config_path = config_dir.join("config.toml");
if config_path.exists() && config_path.is_file() {
@@ -50,14 +71,15 @@ impl ConfigManager {
return Ok(Self {
tree_config: Arc::new(Mutex::new(tree_config)),
path: config_path,
app_handle: Some(app_handle),
});
}
}
Self::create(config_dir).await
Self::create(config_dir, app_handle).await
}
async fn create(config_dir: PathBuf) -> Result<Self, std::io::Error> {
async fn create(config_dir: PathBuf, app_handle: AppHandle) -> Result<Self, std::io::Error> {
let config_path = config_dir.join("config.toml");
let tree_config = ConfigTree {
@@ -68,6 +90,7 @@ impl ConfigManager {
let config = ConfigManager {
tree_config: Arc::new(Mutex::new(tree_config)),
path: config_path,
app_handle: Some(app_handle),
};
config.save().await?;
@@ -83,10 +106,11 @@ impl ConfigManager {
}
// Lock le mutex pour lire tree_config
let tree_config = self.tree_config.lock();
let serialized = toml::to_string_pretty(&*tree_config)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
drop(tree_config); // Libère le lock explicitement
let serialized = {
let tree_config = self.tree_config.lock();
toml::to_string_pretty(&*tree_config)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?
};
fs::write(&self.path, serialized).await?;
Ok(())

View File

@@ -1,2 +1,3 @@
mod config;
pub mod commands;
pub mod config;
pub use config::ConfigManager;

View File

@@ -13,8 +13,9 @@
// .expect("error while running tauri application");
// }
mod app;
pub mod tauri_app;
mod utils;
mod app;
pub mod api;
mod config;

View File

@@ -1,4 +1,8 @@
use crate::api::commands::*;
use crate::app::ox_speak_app::OxSpeakApp;
use crate::app::state::AppState;
use crate::config::commands::*;
use crate::config::ConfigManager;
use std::sync::Arc;
use tauri::{Manager, WindowEvent};
@@ -15,12 +19,43 @@ pub async fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.manage(AppState::new())
.invoke_handler(tauri::generate_handler![
greet,
api_login,
api_claim_admin,
api_ssh_challenge,
api_server_list,
api_server_create,
api_category_list,
api_category_create,
api_channel_list,
api_channel_create,
api_message_list,
api_message_create,
api_user_list,
config_get,
config_update,
])
.setup(|app| {
let handle = app.handle().clone();
// Démarrer le backend
tauri::async_runtime::spawn(async move {
let ox_speak = Arc::new(OxSpeakApp::new(handle.clone()).await);
let app_state = handle.state::<AppState>();
let config_dir = handle
.path()
.app_config_dir()
.expect("Failed to get app config dir");
let config = ConfigManager::load(config_dir, handle.clone())
.await
.expect("Failed to load config");
// Mettre la config dans AppState
let _ = app_state.config.set(config.clone());
let ox_speak = Arc::new(OxSpeakApp::new(handle.clone(), config).await);
handle.manage(ox_speak.clone());
ox_speak.run().await;
});