pré-rework

This commit is contained in:
2026-03-24 17:44:44 +01:00
parent 1342123990
commit aba2cfba7b
42 changed files with 3634 additions and 1531 deletions

View File

@@ -1,12 +1,19 @@
<script setup lang="ts">
import {listen} from "@tauri-apps/api/event";
import {onMounted, onUnmounted, ref} from 'vue';
import {NavigationMenuItem} from "@nuxt/ui";
import {createApiClient} from "./api";
import {useConfigStore, useServerStore} from "./stores";
// stores
const configStore = useConfigStore();
const serverStore = useServerStore();
// vars
let unlisten: (() => void) | null = null;
const apiClient = createApiClient('http://localhost:7000'); // TODO: récupérer de la config
const servers = ref<NavigationMenuItem[]>([
{ label: 'Main Canal', to: '/', icon: 'i-lucide-house'},
{label: 'Main Canal', to: '/', icon: 'i-lucide-house'},
{label: 'Config', to: '/config', icon: 'i-lucide-settings'}
])
// methods
@@ -20,18 +27,17 @@ const getInitials = (name: string) => {
}
async function fetchServers() {
// todo : Cette méthode devra appeler la liste des serveurs via le "proxy" rust
const response = await fetch("http://localhost:7000/api/server/servers/")
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
let data = await response.json()
data.forEach(server => {
servers.value.push({
label: server.name,
to: `/server/${server.id}`, avatar: {text: getInitials(server.name)}
try {
const data = await apiClient.getServerList();
data.forEach(server => {
servers.value.push({
label: server.name,
to: `/server/${server.id}`, avatar: {text: getInitials(server.name)}
})
})
})
} catch (e) {
console.error("Failed to fetch servers", e);
}
}
@@ -42,10 +48,8 @@ async function fetchServers() {
// ])
onMounted(async () => {
// tauri
// unlisten = await listen("app-ready", event => {
// console.log("App is ready", event)
// })
// config
await configStore.init();
// fetch servers
await fetchServers()
@@ -62,9 +66,9 @@ onUnmounted(() => {
<UApp>
<UDashboardGroup unit="rem" storage="local">
<UDashboardSidebar
class="bg-[#2B2D31]"
:collapsed-size="4.5"
collapsed
class="bg-[#2B2D31]"
:collapsed-size="4.5"
collapsed
>
<template #header>
@@ -72,9 +76,9 @@ onUnmounted(() => {
<template #default>
<UNavigationMenu
:items="servers"
orientation="vertical"
:ui="{
:items="servers"
orientation="vertical"
:ui="{
link: 'group relative flex items-center justify-center h-12 w-12 mx-auto mb-2',
linkLeadingIcon: 'w-7 h-7 shrink-0',
linkLeadingAvatar: 'w-12 h-12 text-lg',

View File

@@ -24,6 +24,8 @@ export interface IApiClient {
// AUTH
login(req: LoginRequest): Promise<LoginResponse>;
verifyToken(): Promise<UserResponse>;
claimAdmin(req: ClaimAdminRequest): Promise<void>;
sshChallenge(): Promise<SshChallengeResponse>;
@@ -33,6 +35,8 @@ export interface IApiClient {
createServer(req: CreateServerRequest): Promise<ServerResponse>;
getServerTree(serverId: string): Promise<any>;
// CATEGORY
getCategoryList(serverId?: string): Promise<CategoryResponse[]>;

View File

@@ -1,16 +1,19 @@
export * from './types';
export * from './client';
export * from './tauri-client';
export * from './web-client';
import {TauriApiClient} from './tauri-client';
import {WebApiClient} from './web-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).
* Elle retourne une implémentation Web ou Tauri selon l'environnement.
*/
export function createApiClient(baseUrl: string): IApiClient {
// Pour le moment, on retourne systématiquement l'implémentation Tauri.
return new TauriApiClient(baseUrl);
if ((window as any).__TAURI_INTERNALS__) {
return new TauriApiClient(baseUrl);
}
return new WebApiClient(baseUrl);
}

View File

@@ -29,6 +29,10 @@ export class TauriApiClient implements IApiClient {
return await invoke<LoginResponse>('api_login', {baseUrl: this.baseUrl, req});
}
async verifyToken(): Promise<UserResponse> {
return await invoke<UserResponse>('api_verify_token', {baseUrl: this.baseUrl});
}
async claimAdmin(req: ClaimAdminRequest): Promise<void> {
return await invoke<void>('api_claim_admin', {baseUrl: this.baseUrl, req});
}
@@ -46,6 +50,10 @@ export class TauriApiClient implements IApiClient {
return await invoke<ServerResponse>('api_server_create', {baseUrl: this.baseUrl, req});
}
async getServerTree(serverId: string): Promise<any> {
return await invoke<any>('api_server_tree', {baseUrl: this.baseUrl, serverId});
}
// CATEGORY
async getCategoryList(serverId?: string): Promise<CategoryResponse[]> {
return await invoke<CategoryResponse[]>('api_category_list', {

135
src/api/web-client.ts Normal file
View File

@@ -0,0 +1,135 @@
import type {IApiClient} from './client';
import type {
CategoryResponse,
ChannelResponse,
ClaimAdminRequest,
CreateCategoryRequest,
CreateChannelRequest,
CreateMessageRequest,
CreateServerRequest,
LoginRequest,
LoginResponse,
MessageResponse,
ServerResponse,
SshChallengeResponse,
UserResponse,
} from './types';
/**
* Implémentation Web (standard) de l'interface API.
* Elle utilise fetch() pour communiquer directement avec le serveur.
*/
export class WebApiClient implements IApiClient {
private token: string | null = null;
constructor(public readonly baseUrl: string) {
this.token = localStorage.getItem(`token_${baseUrl}`);
}
private async request<T>(method: string, path: string, body?: any): Promise<T> {
// Nettoyer l'URL
const cleanBaseUrl = this.baseUrl.endsWith('/') ? this.baseUrl.slice(0, -1) : this.baseUrl;
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
const url = `${cleanBaseUrl}/${cleanPath}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
const response = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
let errorData;
try {
errorData = await response.json();
} catch (e) {
errorData = {message: await response.text()};
}
throw new Error(errorData.message || `Erreur HTTP ${response.status}`);
}
if (response.status === 204) {
return {} as T;
}
return await response.json();
}
private setToken(token: string) {
this.token = token;
localStorage.setItem(`token_${this.baseUrl}`, token);
}
// AUTH
async login(req: LoginRequest): Promise<LoginResponse> {
const res = await this.request<LoginResponse>('POST', 'api/auth/login', req);
this.setToken(res.token);
return res;
}
async verifyToken(): Promise<UserResponse> {
return await this.request<UserResponse>('GET', 'api/auth/me');
}
async claimAdmin(req: ClaimAdminRequest): Promise<void> {
return await this.request<void>('POST', 'api/auth/claim-admin', req);
}
async sshChallenge(): Promise<SshChallengeResponse> {
return await this.request<SshChallengeResponse>('POST', 'api/auth/ssh-challenge');
}
// SERVER
async getServerList(): Promise<ServerResponse[]> {
return await this.request<ServerResponse[]>('GET', 'api/server');
}
async createServer(req: CreateServerRequest): Promise<ServerResponse> {
return await this.request<ServerResponse>('POST', 'api/server', req);
}
async getServerTree(serverId: string): Promise<any> {
return await this.request<any>('GET', `api/server/servers/${serverId}/tree/`);
}
// CATEGORY
async getCategoryList(serverId?: string): Promise<CategoryResponse[]> {
const path = serverId ? `api/category?server_id=${serverId}` : 'api/category';
return await this.request<CategoryResponse[]>('GET', path);
}
async createCategory(req: CreateCategoryRequest): Promise<CategoryResponse> {
return await this.request<CategoryResponse>('POST', 'api/category', req);
}
// CHANNEL
async getChannelList(): Promise<ChannelResponse[]> {
return await this.request<ChannelResponse[]>('GET', 'api/channel');
}
async createChannel(req: CreateChannelRequest): Promise<ChannelResponse> {
return await this.request<ChannelResponse>('POST', 'api/channel', req);
}
// MESSAGE
async getMessageList(): Promise<MessageResponse[]> {
return await this.request<MessageResponse[]>('GET', 'api/message');
}
async createMessage(req: CreateMessageRequest): Promise<MessageResponse> {
return await this.request<MessageResponse>('POST', 'api/message', req);
}
// USER
async getUserList(): Promise<UserResponse[]> {
return await this.request<UserResponse[]>('GET', 'api/user');
}
}

View File

@@ -1,15 +1,17 @@
import './assets/main.css'
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import {createApp} from 'vue'
import {createRouter, createWebHistory} from 'vue-router'
import ui from '@nuxt/ui/vue-plugin'
import App from './App.vue'
import {createPinia} from "pinia";
const app = createApp(App)
const router = createRouter({
routes: [
{ path: '/', component: () => import('./pages/index.vue') },
{path: '/', component: () => import('./pages/index.vue')},
{path: '/config', component: () => import('./pages/config.vue')},
{
path: '/server/:server_id',
component: () => import('./pages/server_detail.vue'),
@@ -28,5 +30,5 @@ const router = createRouter({
app.use(router)
app.use(ui)
app.use(createPinia())
app.mount('#app')

240
src/pages/config.vue Normal file
View File

@@ -0,0 +1,240 @@
<script setup lang="ts">
import {onMounted, ref} from 'vue'
import {createConfigClient} from '../config'
// --- Types ---
// Ils sont maintenant importés ou définis via l'interface du client
const configClient = createConfigClient()
const config = ref<any>({
servers: [],
identities: []
})
const loading = ref(false)
const toast = useToast()
const items = [{
label: 'Serveurs',
icon: 'i-lucide-server',
slot: 'servers'
}, {
label: 'Identités',
icon: 'i-lucide-user',
slot: 'identities'
}]
// --- Methods ---
async function fetchConfig() {
loading.value = true
try {
const res = await configClient.get()
config.value = res
} catch (error) {
console.error('Erreur lors de la récupération de la config:', error)
toast.add({title: 'Erreur', description: 'Impossible de charger la configuration.', color: 'red'})
} finally {
loading.value = false
}
}
async function saveConfig() {
loading.value = true
try {
await configClient.update(config.value)
// Re-fetch config to get any auto-generated keys
await fetchConfig()
toast.add({title: 'Succès', description: 'Configuration sauvegardée.', color: 'green'})
} catch (error) {
console.error('Erreur lors de la sauvegarde de la config:', error)
toast.add({title: 'Erreur', description: 'Impossible de sauvegarder la configuration.', color: 'red'})
} finally {
loading.value = false
}
}
// async function generateKey(index: number) {
// try {
// // Note: generate_ssh_key n'est pas encore dans IConfigClient, on peut l'ajouter si besoin
// // ou garder un invoke direct si c'est spécifique à Tauri, mais ici on veut de l'abstrait.
// // Pour le moment on utilise invoke car c'est un utilitaire.
// const {invoke} from '@tauri-apps/api/core'
// const key = await invoke<string>('generate_ssh_key')
// config.value.identities[index].private_key = key
// toast.add({title: 'Succès', description: 'Clé SSH générée.', color: 'green'})
// } catch (error) {
// console.error('Erreur lors de la génération de la clé:', error)
// toast.add({title: 'Erreur', description: 'Impossible de générer la clé.', color: 'red'})
// }
// }
function addServer() {
config.value.servers.push({adresse: '', identity: ''})
}
function removeServer(index: number) {
config.value.servers.splice(index, 1)
}
function addIdentity() {
config.value.identities.push({
id: crypto.randomUUID(),
username: '',
private_key: '',
mode: 'private_key_path'
})
}
function removeIdentity(index: number) {
config.value.identities.splice(index, 1)
}
onMounted(() => {
fetchConfig()
})
</script>
<template>
<div class="p-6 max-w-4xl mx-auto overflow-y-auto h-full">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">Configuration</h1>
<UButton
icon="i-lucide-save"
color="primary"
:loading="loading"
@click="saveConfig"
>
Sauvegarder
</UButton>
</div>
<UTabs :items="items" class="w-full">
<template #servers>
<div class="space-y-4 py-4">
<div v-for="(server, index) in config.servers" :key="index"
class="p-4 border border-gray-200 dark:border-gray-800 rounded-lg relative bg-white dark:bg-gray-900 shadow-sm">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<UFormField label="Adresse du serveur (IP:Port)">
<UInput v-model="server.adresse" placeholder="ex: 127.0.0.1:50051" class="w-full"/>
</UFormField>
<UFormField label="Identité à utiliser">
<USelect
v-model="server.identity"
:items="config.identities.map(i => ({ label: i.username || i.id, value: i.id }))"
placeholder="Sélectionner une identité"
class="w-full"
/>
</UFormField>
</div>
<UButton
icon="i-lucide-trash"
color="red"
variant="ghost"
size="sm"
class="absolute top-2 right-2"
@click="removeServer(index)"
/>
</div>
<UButton
icon="i-lucide-plus"
variant="dashed"
block
@click="addServer"
>
Ajouter un serveur
</UButton>
</div>
</template>
<template #identities>
<div class="space-y-4 py-4">
<div v-for="(identity, index) in config.identities" :key="identity.id"
class="p-4 border border-gray-200 dark:border-gray-800 rounded-lg relative bg-white dark:bg-gray-900 shadow-sm">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<UFormField label="ID d'identité (unique)">
<UInput v-model="identity.id" placeholder="ex: mon-id" class="w-full"/>
</UFormField>
<UFormField label="Nom d'utilisateur">
<UInput v-model="identity.username" placeholder="ex: MonPseudo" class="w-full"/>
</UFormField>
<UFormField label="Mode d'authentification" class="md:col-span-2">
<USelect
v-model="identity.mode"
:items="[
{ label: 'Clé (Fichier)', value: 'private_key_path' },
{ label: 'Clé (Base64)', value: 'private_key_base64' },
{ label: 'Login uniquement', value: 'login' }
]"
class="w-full"
/>
</UFormField>
<template v-if="identity.mode === 'private_key_path' || identity.mode === 'private_key_base64'">
<UFormField :label="identity.mode === 'private_key_path' ? 'Chemin de la clé' : 'Clé privée (Base64)'"
class="md:col-span-2">
<div class="space-y-2">
<UTextarea
v-model="identity.private_key"
:placeholder="identity.mode === 'private_key_path' ? 'ex: ~/.ssh/id_rsa' : 'Entrez la clé encodée en base64 (Si vide, une clé sera générée au besoin)'"
class="w-full"
/>
<!-- <UButton-->
<!-- v-if="identity.mode === 'private_key_base64'"-->
<!-- icon="i-lucide-key"-->
<!-- variant="subtle"-->
<!-- size="xs"-->
<!-- label="Générer une clé SSH"-->
<!-- @click="generateKey(index)"-->
<!-- />-->
</div>
</UFormField>
</template>
<template v-if="identity.token">
<UFormField label="Jeton JWT (Persistant)" class="md:col-span-2">
<div class="flex gap-2">
<UInput v-model="identity.token" readonly class="flex-1 font-mono text-xs"/>
<UButton
icon="i-lucide-copy"
variant="ghost"
color="neutral"
@click="() => {
navigator.clipboard.writeText(identity.token || '')
toast.add({ title: 'Copié', description: 'Jeton JWT copié dans le presse-papier', color: 'green' })
}"
/>
</div>
</UFormField>
</template>
</div>
<UButton
icon="i-lucide-trash"
color="red"
variant="ghost"
size="sm"
class="absolute top-2 right-2"
@click="removeIdentity(index)"
/>
</div>
<UButton
icon="i-lucide-plus"
variant="dashed"
block
@click="addIdentity"
>
Ajouter une identité
</UButton>
</div>
</template>
</UTabs>
</div>
</template>

View File

@@ -1,44 +1,49 @@
<script setup lang="ts">
import {ref, computed, onMounted} from 'vue';
import {onMounted, ref} from 'vue';
import {NavigationMenuItem} from "@nuxt/ui";
import {createApiClient} from "../api";
// vars
const props = defineProps<{
server_id: string
}>()
const channels = ref<NavigationMenuItem[]>([])
const apiClient = createApiClient('http://localhost:7000'); // TODO: récupérer de la config
// methods
async function fetchTree(){
const response = await fetch(`http://localhost:7000/api/server/servers/${props.server_id}/tree/`)
let data = await response.json()
async function fetchTree() {
try {
const data = await apiClient.getServerTree(props.server_id);
const formattedChannels: any[] = []
const formattedChannels: any[] = []
data.forEach(el => {
if (el.type === "category") {
// On crée un groupe pour la catégorie
const categoryGroup = {
label: el.name.toUpperCase(),
type: 'label', // Nuxt UI utilisera ceci comme un label non cliquable
children: el.channels.map((ch: any) => ({
label: ch.name,
icon: 'i-heroicons-hashtag', // Un petit hash pour le style
to: `/server/${props.server_id}/channel/${ch.id}`
}))
data.forEach((el: any) => {
if (el.type === "category") {
// On crée un groupe pour la catégorie
const categoryGroup = {
label: el.name.toUpperCase(),
type: 'label', // Nuxt UI utilisera ceci comme un label non cliquable
children: el.channels.map((ch: any) => ({
label: ch.name,
icon: 'i-heroicons-hashtag', // Un petit hash pour le style
to: `/server/${props.server_id}/channel/${ch.id}`
}))
}
formattedChannels.push(categoryGroup)
} else if (el.type === "channel") {
// Pour les "orphelins" (ceux sans catégorie), on les met à la racine
formattedChannels.push({
label: el.name,
icon: 'i-heroicons-hashtag',
to: `/server/${props.server_id}/channel/${el.id}`
})
}
formattedChannels.push(categoryGroup)
} else if (el.type === "channel") {
// Pour les "orphelins" (ceux sans catégorie), on les met à la racine
formattedChannels.push({
label: el.name,
icon: 'i-heroicons-hashtag',
to: `/server/${props.server_id}/channel/${el.id}`
})
}
})
})
channels.value = formattedChannels
channels.value = formattedChannels
} catch (e) {
console.error("Failed to fetch tree", e);
}
}
// const channels = ref<NavigationMenuItem[]>([
@@ -61,7 +66,7 @@ onMounted(() => {
/>
</template>
</UDashboardSidebar>
<router-view />
<router-view/>
</template>
<style scoped>

39
src/stores/config.ts Normal file
View File

@@ -0,0 +1,39 @@
import {defineStore} from 'pinia';
import {ref} from 'vue';
import {type ConfigTree, createConfigClient} from '../config';
export const useConfigStore = defineStore('config', () => {
const client = createConfigClient();
const config = ref<ConfigTree | null>(null);
const loading = ref(true);
async function loadConfig() {
loading.value = true;
try {
config.value = await client.get();
} finally {
loading.value = false;
}
}
async function updateConfig(newConfig: ConfigTree) {
await client.update(newConfig);
config.value = newConfig;
}
// Initialiser la configuration et écouter les changements
async function init() {
await loadConfig();
await client.onChanged((updatedConfig) => {
config.value = updatedConfig;
});
}
return {
config,
loading,
loadConfig,
updateConfig,
init,
};
});

22
src/stores/global.ts Normal file
View File

@@ -0,0 +1,22 @@
import {defineStore} from 'pinia';
import {ref} from 'vue';
export const useGlobalStore = defineStore('global', () => {
const loading = ref(false);
const theme = ref('dark');
function setLoading(value: boolean) {
loading.value = value;
}
function toggleTheme() {
theme.value = theme.value === 'dark' ? 'light' : 'dark';
}
return {
loading,
theme,
setLoading,
toggleTheme,
};
});

5
src/stores/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export * from './global';
export * from './config';
export * from './server_manager';
export * from './server_context';
export * from './server';

29
src/stores/server.ts Normal file
View File

@@ -0,0 +1,29 @@
import {defineStore} from 'pinia';
import {computed, ref} from 'vue';
import {useConfigStore} from './config';
import {useServerManagerStore} from './server_manager';
import {defineServerContextStore} from './server_context';
export const useServerStore = defineStore('server', () => {
const configStore = useConfigStore();
const managerStore = useServerManagerStore();
const currentServerId = ref<string | null>(null);
// Getter pour obtenir le contexte du serveur actuel
const currentContext = computed(() => {
if (!currentServerId.value) return null;
return defineServerContextStore(currentServerId.value)();
});
function selectServer(serverId: string) {
currentServerId.value = serverId;
managerStore.addServer(serverId);
}
return {
currentServerId,
currentContext,
selectServer,
};
});

View File

@@ -0,0 +1,68 @@
import {defineStore} from 'pinia';
import {ref} from 'vue';
import {createApiClient} from '../api';
export interface ServerState {
id: string;
name: string;
address: string;
isConnected: boolean;
isConnecting: boolean;
error: string | null;
}
// Nous utilisons une fonction pour générer une définition de store unique par serveur
export const defineServerContextStore = (serverId: string) => {
return defineStore(`server_context:${serverId}`, () => {
const state = ref<ServerState>({
id: serverId,
name: '',
address: '',
isConnected: false,
isConnecting: false,
error: null,
});
const apiClient = ref<ReturnType<typeof createApiClient> | null>(null);
const subServers = ref<any[]>([]); // Liste des sous-serveurs virtuels
function init(name: string, address: string) {
state.value.name = name;
state.value.address = address;
apiClient.value = createApiClient(address);
}
function setConnectionState(connected: boolean) {
state.value.isConnected = connected;
state.value.isConnecting = false;
}
function setConnecting(connecting: boolean) {
state.value.isConnecting = connecting;
}
function setError(err: string | null) {
state.value.error = err;
}
async function fetchSubServers() {
if (!apiClient.value) return;
try {
const servers = await apiClient.value.getServerList();
subServers.value = servers;
} catch (e: any) {
setError(e.message || "Failed to fetch sub-servers");
}
}
return {
state,
subServers,
init,
setConnectionState,
setConnecting,
setError,
fetchSubServers,
};
});
};

View File

@@ -0,0 +1,29 @@
import {defineStore} from 'pinia';
import {ref} from 'vue';
export const useServerManagerStore = defineStore('server_manager', () => {
// Liste des IDs de serveurs actuellement chargés/connectés
const connectedServerIds = ref<string[]>([]);
// Pour obtenir un contexte spécifique, l'utilisateur du store appellera defineServerContextStore(id)()
// Le manager peut aider à orchestrer ces connexions.
function addServer(serverId: string) {
if (!connectedServerIds.value.includes(serverId)) {
connectedServerIds.value.push(serverId);
}
}
function removeServer(serverId: string) {
const index = connectedServerIds.value.indexOf(serverId);
if (index !== -1) {
connectedServerIds.value.splice(index, 1);
}
}
return {
connectedServerIds,
addServer,
removeServer,
};
});

55
src/ws/README.md Normal file
View File

@@ -0,0 +1,55 @@
# Système WebSocket OxSpeak
Ce module fournit une interface WebSocket générique pour communiquer avec les serveurs OxSpeak.
## Utilisation
Le système utilise une interface générique `IWsClient` qui supporte automatiquement deux environnements :
- **Tauri** : La connexion est gérée par le backend Rust (meilleure stabilité, reconnexion automatique gérée en Rust).
- **Web** : Utilise l'API standard `WebSocket` du navigateur.
### Exemple de base
```typescript
import { createWsClient } from '@/ws';
const wsClient = createWsClient();
async function startChat(serverUrl: string, token: string) {
// 1. Connexion
await wsClient.connect(serverUrl, token);
// 2. Écoute des messages
const unsubscribe = wsClient.onMessage((payload) => {
console.log('Nouveau message reçu:', payload);
});
// 3. Envoi d'un message
await wsClient.send({
type: 'chat_message',
content: 'Bonjour tout le monde !'
});
// Plus tard pour se déconnecter
// await wsClient.disconnect();
// unsubscribe();
}
```
### Avantages de l'implémentation Tauri
L'implémentation Tauri via Rust offre plusieurs avantages :
1. **Reconnexion automatique** : Le backend Rust tente de se reconnecter toutes les 5 secondes en cas de perte de
connexion.
2. **Persistence** : La connexion survit aux rechargements de la page frontend (HMR ou F5).
3. **Multi-serveur** : Le `WsManager` en Rust peut maintenir plusieurs connexions actives simultanément pour différents
serveurs.
## Structure du Backend (Rust)
- `WsClient` : Gère la boucle de connexion, lecture et écriture pour un serveur donné.
- `WsManager` : Gère le dictionnaire des connexions actives.
- `commands.rs` : Expose les fonctions `ws_connect`, `ws_send` et `ws_disconnect` à Tauri.
- Événement `ws-message` : Émis par Rust vers le Frontend pour chaque message reçu.

113
src/ws/client.ts Normal file
View File

@@ -0,0 +1,113 @@
import {invoke} from '@tauri-apps/api/core';
import {listen} from '@tauri-apps/api/event';
import type {IWsClient, WsEvent} from './types';
/**
* Implémentation Tauri du client WebSocket.
* Utilise le backend Rust pour maintenir la connexion.
*/
export class TauriWsClient implements IWsClient {
private serverUrl: string | null = null;
async connect(serverUrl: string, token?: string): Promise<void> {
this.serverUrl = serverUrl;
await invoke('ws_connect', {serverUrl, token});
}
async send(message: any): Promise<void> {
if (!this.serverUrl) throw new Error('Non connecté');
const msgString = typeof message === 'string' ? message : JSON.stringify(message);
await invoke('ws_send', {serverUrl: this.serverUrl, message: msgString});
}
async disconnect(): Promise<void> {
if (this.serverUrl) {
await invoke('ws_disconnect', {serverUrl: this.serverUrl});
this.serverUrl = null;
}
}
onMessage(callback: (payload: any) => void): () => void {
let unlisten: (() => void) | null = null;
listen<WsEvent>('ws-message', (event) => {
// Filtrer par serverUrl si nécessaire
if (this.serverUrl && event.payload.server_url === this.serverUrl) {
callback(event.payload.payload);
}
}).then((fn) => {
unlisten = fn;
});
return () => {
if (unlisten) unlisten();
};
}
}
/**
* Implémentation Web (standard) du client WebSocket.
*/
export class WebWsClient implements IWsClient {
private ws: WebSocket | null = null;
private messageCallbacks: Set<(payload: any) => void> = new Set();
private serverUrl: string | null = null;
async connect(serverUrl: string, token?: string): Promise<void> {
this.serverUrl = serverUrl;
let wsUrl = serverUrl.replace('http://', 'ws://').replace('https://', 'wss://');
if (!wsUrl.endsWith('/ws')) {
wsUrl = `${wsUrl.replace(/\/$/, '')}/ws`;
}
if (token) {
wsUrl += (wsUrl.includes('?') ? '&' : '?') + `token=${token}`;
}
return new Promise((resolve, reject) => {
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => resolve();
this.ws.onerror = (err) => reject(err);
this.ws.onmessage = (event) => {
try {
const payload = JSON.parse(event.data);
this.messageCallbacks.forEach((cb) => cb(payload));
} catch (e) {
// Si ce n'est pas du JSON, on renvoie la data brute
this.messageCallbacks.forEach((cb) => cb(event.data));
}
};
});
}
async send(message: any): Promise<void> {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error('WebSocket non ouvert');
}
const msgString = typeof message === 'string' ? message : JSON.stringify(message);
this.ws.send(msgString);
}
async disconnect(): Promise<void> {
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
onMessage(callback: (payload: any) => void): () => void {
this.messageCallbacks.add(callback);
return () => this.messageCallbacks.delete(callback);
}
}
/**
* Factory pour créer le client approprié.
*/
export function createWsClient(): IWsClient {
if ((window as any).__TAURI_INTERNALS__) {
return new TauriWsClient();
}
return new WebWsClient();
}

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

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

27
src/ws/types.ts Normal file
View File

@@ -0,0 +1,27 @@
export interface WsEvent {
server_url: string;
payload: any;
}
export interface IWsClient {
/**
* Se connecte au serveur WebSocket.
*/
connect(serverUrl: string, token?: string): Promise<void>;
/**
* Envoie un message au serveur.
*/
send(message: any): Promise<void>;
/**
* Se déconnecte du serveur.
*/
disconnect(): Promise<void>;
/**
* S'abonne aux messages entrants.
* Retourne une fonction pour se désabonner.
*/
onMessage(callback: (payload: any) => void): () => void;
}