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

1
resources/openapi.json Normal file

File diff suppressed because one or more lines are too long

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;
});

68
src/api/README.md Normal file
View File

@@ -0,0 +1,68 @@
# OxSpeak API Client (TypeScript)
Cette interface permet de communiquer avec les serveurs OxSpeak via Tauri (et plus tard via le Web).
## Installation
Assurez-vous d'avoir installé les dépendances Tauri dans votre projet frontend :
```bash
yarn add @tauri-apps/api
```
## Utilisation
L'API est conçue pour supporter plusieurs serveurs simultanément. Chaque serveur est représenté par une instance de
`IApiClient`.
### Créer un client
```typescript
import { createApiClient } from './api';
const client = createApiClient('http://localhost:8080');
```
### Authentification
```typescript
try {
const response = await client.login({
username: 'mon_pseudo',
password: 'mon_password'
});
console.log('Connecté ! Token:', response.token);
} catch (error) {
console.error('Erreur de connexion:', error);
}
```
### Lister les serveurs
```typescript
const servers = await client.getServerList();
servers.forEach(s => console.log(`Serveur: ${s.name} (${s.id})`));
```
### Créer un canal
```typescript
import { ChannelType } from './api';
const newChannel = await client.createChannel({
name: 'Général',
channel_type: 'text',
server_id: 'server-123'
});
```
## Architecture
L'interface est séparée en trois parties pour permettre une évolution vers une version Web sans casser le code
existant :
1. `types.ts` : Les définitions d'interfaces de données (identiques aux modèles Rust).
2. `client.ts` : L'interface `IApiClient` définissant le contrat.
3. `tauri-client.ts` : L'implémentation concrète utilisant Tauri `invoke`.
Le fichier `index.ts` expose une usine `createApiClient` qui instancie la bonne implémentation.

53
src/api/client.ts Normal file
View File

@@ -0,0 +1,53 @@
import type {
CategoryResponse,
ChannelResponse,
ClaimAdminRequest,
CreateCategoryRequest,
CreateChannelRequest,
CreateMessageRequest,
CreateServerRequest,
LoginRequest,
LoginResponse,
MessageResponse,
ServerResponse,
SshChallengeResponse,
UserResponse,
} from './types';
/**
* Interface générique pour l'API OxSpeak.
* Elle permet de découpler l'implémentation (Tauri, Web, etc.) de l'utilisation.
*/
export interface IApiClient {
readonly baseUrl: string;
// AUTH
login(req: LoginRequest): Promise<LoginResponse>;
claimAdmin(req: ClaimAdminRequest): Promise<void>;
sshChallenge(): Promise<SshChallengeResponse>;
// SERVER
getServerList(): Promise<ServerResponse[]>;
createServer(req: CreateServerRequest): Promise<ServerResponse>;
// CATEGORY
getCategoryList(serverId?: string): Promise<CategoryResponse[]>;
createCategory(req: CreateCategoryRequest): Promise<CategoryResponse>;
// CHANNEL
getChannelList(): Promise<ChannelResponse[]>;
createChannel(req: CreateChannelRequest): Promise<ChannelResponse>;
// MESSAGE
getMessageList(): Promise<MessageResponse[]>;
createMessage(req: CreateMessageRequest): Promise<MessageResponse>;
// USER
getUserList(): Promise<UserResponse[]>;
}

16
src/api/index.ts Normal file
View File

@@ -0,0 +1,16 @@
export * from './types';
export * from './client';
export * from './tauri-client';
import {TauriApiClient} from './tauri-client';
import type {IApiClient} from './client';
/**
* Usine pour créer des clients API.
* Plus tard, cette usine pourra retourner une implémentation Web ou Tauri
* selon l'environnement (isTauri).
*/
export function createApiClient(baseUrl: string): IApiClient {
// Pour le moment, on retourne systématiquement l'implémentation Tauri.
return new TauriApiClient(baseUrl);
}

83
src/api/tauri-client.ts Normal file
View File

@@ -0,0 +1,83 @@
import {invoke} from '@tauri-apps/api/core';
import type {IApiClient} from './client';
import type {
CategoryResponse,
ChannelResponse,
ClaimAdminRequest,
CreateCategoryRequest,
CreateChannelRequest,
CreateMessageRequest,
CreateServerRequest,
LoginRequest,
LoginResponse,
MessageResponse,
ServerResponse,
SshChallengeResponse,
UserResponse,
} from './types';
/**
* Implémentation Tauri de l'interface API.
* Cette version utilise 'invoke' pour communiquer avec le backend Rust.
*/
export class TauriApiClient implements IApiClient {
constructor(public readonly baseUrl: string) {
}
// AUTH
async login(req: LoginRequest): Promise<LoginResponse> {
return await invoke<LoginResponse>('api_login', {baseUrl: this.baseUrl, req});
}
async claimAdmin(req: ClaimAdminRequest): Promise<void> {
return await invoke<void>('api_claim_admin', {baseUrl: this.baseUrl, req});
}
async sshChallenge(): Promise<SshChallengeResponse> {
return await invoke<SshChallengeResponse>('api_ssh_challenge', {baseUrl: this.baseUrl});
}
// SERVER
async getServerList(): Promise<ServerResponse[]> {
return await invoke<ServerResponse[]>('api_server_list', {baseUrl: this.baseUrl});
}
async createServer(req: CreateServerRequest): Promise<ServerResponse> {
return await invoke<ServerResponse>('api_server_create', {baseUrl: this.baseUrl, req});
}
// CATEGORY
async getCategoryList(serverId?: string): Promise<CategoryResponse[]> {
return await invoke<CategoryResponse[]>('api_category_list', {
baseUrl: this.baseUrl,
serverId: serverId ?? null
});
}
async createCategory(req: CreateCategoryRequest): Promise<CategoryResponse> {
return await invoke<CategoryResponse>('api_category_create', {baseUrl: this.baseUrl, req});
}
// CHANNEL
async getChannelList(): Promise<ChannelResponse[]> {
return await invoke<ChannelResponse[]>('api_channel_list', {baseUrl: this.baseUrl});
}
async createChannel(req: CreateChannelRequest): Promise<ChannelResponse> {
return await invoke<ChannelResponse>('api_channel_create', {baseUrl: this.baseUrl, req});
}
// MESSAGE
async getMessageList(): Promise<MessageResponse[]> {
return await invoke<MessageResponse[]>('api_message_list', {baseUrl: this.baseUrl});
}
async createMessage(req: CreateMessageRequest): Promise<MessageResponse> {
return await invoke<MessageResponse>('api_message_create', {baseUrl: this.baseUrl, req});
}
// USER
async getUserList(): Promise<UserResponse[]> {
return await invoke<UserResponse[]>('api_user_list', {baseUrl: this.baseUrl});
}
}

100
src/api/types.ts Normal file
View File

@@ -0,0 +1,100 @@
// --- AUTH ---
export interface LoginRequest {
username: string;
password?: string;
}
export interface LoginResponse {
token: string;
username: string;
}
export interface ClaimAdminRequest {
token: string;
username: string;
password?: string;
}
export interface SshChallengeResponse {
challenge: string;
}
// --- SERVER ---
export interface CreateServerRequest {
name: string;
password?: string;
}
export interface ServerResponse {
id: string;
name: string;
created_at: string;
updated_at: string;
}
// --- CATEGORY ---
export interface CreateCategoryRequest {
server_id: string;
name: string;
}
export interface CategoryResponse {
id: string;
name: string;
}
// --- CHANNEL ---
export type ChannelType = 'text' | 'voice' | 'd_m';
export interface CreateChannelRequest {
channel_type: ChannelType;
category_id?: string;
name?: string;
position?: number;
server_id?: string;
}
export interface ChannelResponse {
id: string;
name?: string;
position: number;
channel_type: ChannelType;
category_id?: string;
}
// --- MESSAGE ---
export interface CreateMessageRequest {
channel_id: string;
content: string;
}
export interface MessageResponse {
id: string;
channel_id: string;
author_id: string;
content: string;
}
// --- USER ---
export interface CreateUserRequest {
username: string;
}
export interface UserResponse {
id: string;
username: string;
pub_key: string;
}
// --- ERRORS ---
export interface ApiError {
type: string;
data: string | { status: number, body: string };
}

70
src/config/README.md Normal file
View File

@@ -0,0 +1,70 @@
# Système de Configuration OxSpeak
Ce module permet de gérer la configuration globale de l'application (serveurs enregistrés et identités).
## Utilisation
Le système utilise une interface générique `IConfigClient` qui permet d'utiliser le même code pour Tauri et
une version Web (localStorage).
### Détection de l'environnement
La factory `createConfigClient()` détecte automatiquement si l'application tourne dans Tauri ou dans un navigateur
standard :
- **Tauri** : Utilise les commandes Rust et le stockage TOML local.
- **Web** : Utilise le `localStorage` du navigateur.
### Écoute des changements
Vous pouvez écouter les modifications de configuration de manière réactive :
```typescript
const configClient = createConfigClient();
// S'abonner aux changements
const unlisten = await configClient.onChanged((newConfig) => {
console.log('La config a changé !', newConfig);
});
// Plus tard, pour arrêter d'écouter
// unlisten();
```
### Exemple en TypeScript
```typescript
import {createConfigClient} from '@/config';
const configClient = createConfigClient();
// Récupérer la configuration
async function loadConfig() {
try {
const config = await configClient.get();
console.log('Serveurs configurés:', config.servers);
} catch (error) {
console.error('Erreur lors du chargement de la config:', error);
}
}
// Mettre à jour la configuration
async function addServer(name: string, identityId: string) {
const config = await configClient.get();
config.servers.push({
adresse: name,
identity: identityId
});
await configClient.update(config);
console.log('Serveur ajouté !');
}
```
### Structures de données
- **ConfigTree** : La racine de la configuration contenant les serveurs et les identités.
- **ConfigServer** : Définit un serveur (adresse et identité liée).
- **IdentityConfig** : Définit une identité utilisateur avec sa clé privée SSH.
- **IdentityMode** : `private_key_path` (chemin vers le fichier) ou `private_key_base64` (clé brute encodée).

80
src/config/client.ts Normal file
View File

@@ -0,0 +1,80 @@
import {invoke} from '@tauri-apps/api/core';
import {listen} from '@tauri-apps/api/event';
import type {ConfigTree} from './types';
export interface IConfigClient {
get(): Promise<ConfigTree>;
update(config: ConfigTree): Promise<void>;
/**
* Écoute les changements de configuration.
* Retourne une fonction pour arrêter l'écoute.
*/
onChanged(callback: (config: ConfigTree) => void): Promise<() => void>;
}
/**
* Implémentation Tauri pour le client de configuration.
*/
export class TauriConfigClient implements IConfigClient {
async get(): Promise<ConfigTree> {
return await invoke<ConfigTree>('config_get');
}
async update(config: ConfigTree): Promise<void> {
await invoke('config_update', {newConfig: config});
}
async onChanged(callback: (config: ConfigTree) => void): Promise<() => void> {
const unlisten = await listen<ConfigTree>('config-changed', (event) => {
callback(event.payload);
});
return unlisten;
}
}
/**
* Implémentation Web (LocalStorage) pour le client de configuration.
* Utile pour le développement hors-Tauri ou la future version Web.
*/
export class WebConfigClient implements IConfigClient {
private STORAGE_KEY = 'ox-speak-config';
async get(): Promise<ConfigTree> {
const data = localStorage.getItem(this.STORAGE_KEY);
if (data) {
try {
return JSON.parse(data);
} catch (e) {
console.error('Failed to parse config from localStorage', e);
}
}
return {servers: [], identities: []};
}
async update(config: ConfigTree): Promise<void> {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(config));
// Simuler un événement de changement pour les autres composants de la même page
window.dispatchEvent(new CustomEvent('ox-config-changed', {detail: config}));
}
async onChanged(callback: (config: ConfigTree) => void): Promise<() => void> {
const handler = (event: any) => {
callback(event.detail);
};
window.addEventListener('ox-config-changed', handler);
return () => window.removeEventListener('ox-config-changed', handler);
}
}
/**
* Usine pour créer le client de configuration approprié.
*/
export function createConfigClient(): IConfigClient {
// Détection automatique de l'environnement
if ((window as any).__TAURI_INTERNALS__) {
return new TauriConfigClient();
}
return new WebConfigClient();
}

2
src/config/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './types';
export * from './client';

18
src/config/types.ts Normal file
View File

@@ -0,0 +1,18 @@
export type IdentityMode = 'private_key_path' | 'private_key_base64';
export interface IdentityConfig {
id: string;
username: string;
private_key: string;
mode: IdentityMode;
}
export interface ConfigServer {
adresse: string;
identity: string;
}
export interface ConfigTree {
servers: ConfigServer[];
identities: IdentityConfig[];
}