Compare commits

...

10 Commits

Author SHA1 Message Date
benoit a7a488403f Update documentation 2026-06-30 14:18:08 +02:00
benoit 22bb66b680 fix: corriger la détection de statut serveur et l'URL/QR code de connexion clients
Statut serveur :
- SERVER_URL utilisait "localhost", que le Node embarqué par Electron peut
  résoudre en IPv6 (::1) en priorité ; le serveur n'écoutant qu'en IPv4
  (host: 0.0.0.0), le ping de statut échouait silencieusement alors que le
  serveur tournait. Bascule sur 127.0.0.1 (main.js + preload.js).
- L'erreur réelle de pingServer() n'était jamais remontée au renderer
  (health.error perdu) ; elle est maintenant incluse dans la réponse IPC.

URL/QR code clients :
- L'URL affichée utilisait un remplacement de chaîne ("localhost" -> IP) qui
  ne matchait plus rien depuis le passage à 127.0.0.1 ; remplacé par un
  parsing d'URL qui ne réutilise que le protocole/port.
- Le QR code dépendait d'une lib chargée depuis un CDN externe, inadapté à
  une app self-hosted censée fonctionner sans accès Internet sur le WiFi
  d'un événement. Généré désormais côté Main Process avec la lib qrcode
  (déjà en dépendance, jamais utilisée) et transmis au renderer en data URL ;
  suppression du fichier placeholder et de la dépendance CDN.
- getNetworkIP() lisait /admin/config, qui renvoie la valeur YAML brute
  "AUTO" (jamais résolue), donc retombait toujours sur "localhost".
  Remplacé par la détection réseau du Main Process (même logique que pour
  les certificats mkcert).
- Ajout d'un placeholder visuel (icône + message) tant qu'aucun QR code
  n'est généré ou que le serveur est arrêté, en CSS pur.
2026-06-30 14:11:29 +02:00
benoit 144caac183 fix: activer HTTPS par défaut dans l'app Electron et fiabiliser les appels au serveur local
ENABLE_HTTPS était lu depuis l'environnement sans jamais être positionné par le
flow Electron (start-desktop.sh → electron .), donc le serveur enfant tournait
toujours en HTTP malgré le setup mkcert automatique au premier lancement.
ENABLE_HTTPS est désormais activé par défaut (ENABLE_HTTPS=false pour revenir
en HTTP explicitement).

Corrections induites par ce changement de protocole par défaut :
- pingServer() utilisait le module http en dur même en HTTPS ; bascule sur
  https avec rejectUnauthorized: false (ping local vers notre propre process
  enfant, le module https de Node ne lisant pas le trousseau macOS où mkcert
  installe sa CA, contrairement à Safari/Chrome/Electron renderer).
- Le dashboard (electron/ui/app.js) avait l'URL de l'API et celle du
  WebSocket VU-mètres codées en dur en http/ws ; elles utilisent maintenant
  l'URL réelle exposée par preload.js (serverUrl), cohérente avec le
  protocole effectif du serveur.
2026-06-30 13:45:33 +02:00
benoit b7911badb2 fix: corriger conflit upgrade WebSocket entre proxy LiveKit et audio-levels
AudioLevelsServer s'auto-attachait à l'événement 'upgrade' du serveur HTTP
via la lib ws (server + path), en plus du listener manuel du proxy LiveKit.
Pour toute connexion /livekit, les deux listeners s'exécutaient : le proxy
LiveKit aboutissait bien côté upstream, mais le listener ws (path
/audio-levels ne matchant pas) appelait abortHandshake(socket, 400) sur le
même socket juste après, cassant la connexion côté client en HTTPS prod.

AudioLevelsServer passe maintenant en noServer: true et expose
handleUpgrade(), appelée par un dispatcher 'upgrade' unique dans
server/index.js qui route explicitement par chemin (/livekit vs
/audio-levels).

Ajout de certs/ au .gitignore (clés privées SSL locales mkcert).
2026-06-30 13:29:09 +02:00
benoit dfe5db979a feat: ajout proxy HTTP pour routes REST LiveKit
Ajout proxy pour requetes HTTP LiveKit (ex: /rtc/validate).
Le client LiveKit fait d'abord une requete HTTP avant WebSocket.
2026-06-19 14:14:22 +02:00
benoit d3558388ad fix: remplacement http-proxy-middleware par http-proxy natif
http-proxy-middleware ne gere pas correctement WebSocket upgrade avec HTTPS.
Utilisation de http-proxy natif pour proxy direct WSS vers WS.

Modifications:
- Remplacement createProxyMiddleware par httpProxy.createProxyServer
- Gestion native de l'upgrade WebSocket
- Reecriture URL /livekit -> / pour LiveKit
- Logs info niveau pour debug facilite
2026-06-19 14:02:04 +02:00
benoit 8d2b83be0a fix: amelioration gestion upgrade WebSocket pour proxy LiveKit
Ajout logs debug pour tracer les requetes WebSocket upgrade.
Gestion explicite des chemins /livekit et /audio-levels.
2026-06-19 13:58:27 +02:00
benoit 861448f565 fix: utiliser proxy WSS pour LiveKit en mode HTTPS
Correction detection du protocole pour utiliser le bon endpoint LiveKit.

Modifications:
- client/src/App.jsx: detection HTTPS au lieu de import.meta.env.DEV
- Si HTTPS: utiliser wss://host/livekit (proxy)
- Si HTTP: utiliser URL LiveKit directe

Probleme resolu:
- Mixed content error (HTTPS ne peut pas WS)
- Fonctionne maintenant en prod HTTPS build
2026-06-19 13:52:47 +02:00
benoit 32158079c6 feat: ajout proxy WebSocket pour LiveKit en mode HTTPS
Permet au client PWA charge en HTTPS de se connecter a LiveKit via proxy WSS.

Modifications:
- server/index.js: ajout proxy http-proxy-middleware
- Route /livekit proxie vers http://localhost:7880
- Upgrade WebSocket active pour le proxy
- server/package.json: ajout dependance http-proxy-middleware

Fonctionnement:
- Client HTTPS se connecte a wss://localhost:3000/livekit
- Serveur Express proxie vers ws://localhost:7880
- Resout probleme mixed content (HTTPS ne peut pas WS)
2026-06-19 13:49:28 +02:00
benoit c21433b9eb feat: implementation WebSocket VU-metres dans interface desktop
Ajout connexion WebSocket temps reel pour monitoring audio dans l'app Electron.

Modifications:
- electron/ui/app.js: connexion WebSocket /audio-levels
- Rendu VU-metres pour inputs/groups/outputs
- Reconnexion automatique en cas de deconnexion
- Gestion cycle de vie (demarrage/arret serveur)
- electron/ui/styles.css: styles pour VU-metres
- Barres horizontales avec couleurs (vert/jaune/rouge)
- Indicateur peak temps reel
- Animation clipping si saturation
- DESKTOP-APP.md: marque TODO comme complete

Fonctionnalites:
- Affichage niveaux RMS et peak en dBFS
- Detection clipping avec animation
- Status connexion WebSocket visible
- Mise a jour 20 fois/seconde (50ms)
- Sections separees: entrees, groupes, sorties
2026-06-19 13:40:03 +02:00
12 changed files with 576 additions and 70 deletions
+3
View File
@@ -55,3 +55,6 @@ server.log
# Runtime files # Runtime files
/tmp/ptt-live.pid /tmp/ptt-live.pid
# Certificats SSL locaux (mkcert) - contiennent des clés privées
certs/
+64 -26
View File
@@ -5,11 +5,12 @@ Application Electron pour gérer le serveur PTT Live avec interface graphique co
## 📸 Aperçu ## 📸 Aperçu
L'application desktop intègre : L'application desktop intègre :
-**Dashboard temps réel** : stats, utilisateurs, QR Code -**Dashboard temps réel** : stats, utilisateurs, QR Code (généré côté Main Process, sans dépendance CDN)
-**HTTPS automatique** : certificats locaux mkcert installés au premier lancement
-**Configuration audio** : sélection devices, sample rate, bitrate -**Configuration audio** : sélection devices, sample rate, bitrate
-**Gestion groupes** : CRUD complet avec API -**Gestion groupes** : CRUD complet avec API
-**Monitoring** : VU-mètres (prévu), logs filtrables -**Monitoring** : VU-mètres temps réel via WebSocket, logs filtrables
-**Contrôle serveur** : démarrage/arrêt avec feedback visuel -**Contrôle serveur** : démarrage manuel/arrêt avec feedback visuel
--- ---
@@ -24,7 +25,7 @@ cd electron
npm start npm start
``` ```
L'application démarre automatiquement le serveur PTT Live au lancement. Au premier lancement, l'app configure automatiquement les certificats HTTPS locaux (mkcert) — voir [HTTPS et certificats](#-https-et-certificats). Le serveur PTT Live **ne démarre pas automatiquement** : cliquez sur "Démarrer" dans le dashboard pour le lancer.
--- ---
@@ -50,9 +51,12 @@ npm install
- Total connexions - Total connexions
**QR Code** : **QR Code** :
- Généré automatiquement avec l'IP réseau - Généré côté Main Process (lib `qrcode`, pas de CDN externe — fonctionne sans accès Internet sur le WiFi d'un événement)
- IP réseau détectée par le Main Process (même logique que pour les certificats mkcert)
- URL construite à partir du protocole/port réels du serveur (HTTPS par défaut)
- Scanner depuis smartphone pour connexion rapide - Scanner depuis smartphone pour connexion rapide
- Bouton copier URL - Bouton copier URL
- Placeholder visuel tant que le serveur est arrêté ou qu'aucun QR code n'a été généré
**Utilisateurs** : **Utilisateurs** :
- Liste en temps réel - Liste en temps réel
@@ -80,10 +84,10 @@ npm install
### 4. Monitoring ### 4. Monitoring
**VU-Mètres** (à venir) : **VU-Mètres** :
- Niveaux audio par canal (input/output) - Niveaux audio par canal (input/output) et par groupe
- Temps réel via WebSocket - Temps réel via WebSocket (`/audio-levels`, même port que l'API)
- Détection clipping - Reconnexion automatique si la connexion WebSocket tombe
### 5. Logs ### 5. Logs
@@ -114,21 +118,24 @@ npm install
│ │ │ │ │ │ │ │
│ │ • HTML/CSS/JS (pas de framework) │ │ │ │ • HTML/CSS/JS (pas de framework) │ │
│ │ • Fetch API REST :3000/admin/* │ │ │ │ • Fetch API REST :3000/admin/* │ │
│ │ • WebSocket audio levels (prévu) │ │ │ │ • WebSocket audio levels (live) │ │
│ │ • QR Code (qrcode.js) │ │ │ │ • QR Code (data URL via IPC) │ │
│ └───────────────────────────────────────────┘ │ │ └───────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘ └─────────────────────────────────────────────────┘
↕ HTTP ↕ HTTPS (127.0.0.1, certs mkcert)
┌─────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────┐
│ SERVEUR PTT LIVE (spawned) │ │ SERVEUR PTT LIVE (spawned) │
│ │ │ │
│ • LiveKit Server (binaire Go) │ • LiveKit Server (binaire Go) :7880
│ • Audio Bridge Manager │ │ • Audio Bridge Manager │
│ • API REST Express :3000 │ • API REST Express :3000 (HTTPS)
│ • WebSocket Audio Levels │ • Proxy HTTP + WS → LiveKit (/livekit/*)
│ • WebSocket Audio Levels (/audio-levels) │
└─────────────────────────────────────────────────┘ └─────────────────────────────────────────────────┘
``` ```
Le proxy `/livekit/*` (http-proxy natif) permet aux clients de joindre LiveKit via le même port/certificat HTTPS que l'API, sans exposer le port 7880 séparément. Le serveur Express dispatch lui-même les événements `upgrade` (un seul listener) entre le proxy LiveKit et le WebSocket audio-levels, qui partagent le même port.
--- ---
## 🔌 API Consommées ## 🔌 API Consommées
@@ -150,9 +157,31 @@ L'interface desktop utilise toutes les routes admin existantes :
| `/admin/devices/list` | GET | Auto-détection (macOS/Linux) | | `/admin/devices/list` | GET | Auto-détection (macOS/Linux) |
| `/admin/logs` | GET | Logs serveur | | `/admin/logs` | GET | Logs serveur |
| `/health` | GET | Health check | | `/health` | GET | Health check |
| `/livekit/*` | ALL | Proxy HTTP vers LiveKit Server (port 7880) |
WebSocket (prévu) : WebSocket :
- `ws://localhost:3000/audio-levels` → VU-mètres temps réel - `wss://127.0.0.1:3000/audio-levels` → VU-mètres temps réel
- `wss://127.0.0.1:3000/livekit/*` → Proxy WebSocket signaling LiveKit (clients PWA)
---
## 🔒 HTTPS et certificats
L'app est en HTTPS par défaut (`ENABLE_HTTPS=false` pour revenir en HTTP explicitement).
### Setup automatique (premier lancement)
Au premier démarrage, si `certs/localhost.pem` et `certs/localhost-key.pem` sont absents, `electron/setup-helper.js` :
1. Installe `mkcert` automatiquement (Homebrew sur macOS, téléchargement direct sur Linux)
2. Installe la CA locale (`mkcert -install`) dans le trousseau système
3. Détecte l'IP réseau et génère les certificats pour `localhost`, `127.0.0.1` et cette IP
4. Affiche des dialogs de progression/erreur (avec fallback manuel `./setup-certificates.sh`)
### Points d'attention
- **127.0.0.1, pas localhost** : le serveur écoute en IPv4 (`host: 0.0.0.0`), mais le Node embarqué par Electron peut résoudre `localhost` en IPv6 (`::1`) en priorité. `main.js` et `preload.js` utilisent donc `127.0.0.1` pour tous les appels internes (ping, health check) afin d'éviter des échecs silencieux.
- **Ping interne et `rejectUnauthorized`** : le module `https` de Node ne lit pas le trousseau système où mkcert installe sa CA (contrairement à Safari/Chrome/Electron renderer) ; `pingServer()` passe donc `rejectUnauthorized: false` pour son propre ping local.
- **Proxy LiveKit en HTTPS** : LiveKit Server local tourne en HTTP brut (port 7880) ; le proxy Express (`http-proxy`) fait le pont HTTPS ↔ HTTP côté clients.
--- ---
@@ -283,17 +312,25 @@ PORT=3001 npm start
- Vérifier permissions LiveKit binaire - Vérifier permissions LiveKit binaire
- Voir logs dans DevTools console - Voir logs dans DevTools console
**Certificats SSL manquants / setup mkcert échoue** :
- Exécuter manuellement : `./setup-certificates.sh`
- Ou installer mkcert : https://github.com/FiloSottile/mkcert puis `mkcert -install`
- Vérifier la présence de `certs/localhost.pem` et `certs/localhost-key.pem`
**Statut serveur affiché à tort comme "arrêté"** :
- Vérifier que le ping utilise bien `127.0.0.1` (pas `localhost`, qui peut résoudre en IPv6 alors que le serveur n'écoute qu'en IPv4)
- En HTTPS, le ping interne ignore volontairement les erreurs de certificat (`rejectUnauthorized: false`) puisque Node ne lit pas le trousseau système où mkcert installe sa CA
**QR Code ne s'affiche pas** : **QR Code ne s'affiche pas** :
- Vérifier que le serveur tourne - Vérifier que le serveur tourne (le QR code est réinitialisé tant qu'il est arrêté)
- Voir console : "✅ QR Code généré" - Le QR code est généré côté Main Process (IPC `qrcode:generate`), pas de dépendance réseau/CDN
- Script CDN chargé ?
--- ---
## 🚧 TODO / Améliorations ## 🚧 TODO / Améliorations
### Priorité haute ### Priorité haute
- [ ] **WebSocket VU-mètres** : implémenter connexion `/audio-levels` - [x] **WebSocket VU-mètres** : implémenter connexion `/audio-levels`
- [ ] **Vraies icônes** : icns/png pour macOS/Linux - [ ] **Vraies icônes** : icns/png pour macOS/Linux
- [ ] **Tray icon** : avec menu contextuel fonctionnel - [ ] **Tray icon** : avec menu contextuel fonctionnel
@@ -304,10 +341,8 @@ PORT=3001 npm start
- [ ] **Notifications desktop** : via Electron Notification API - [ ] **Notifications desktop** : via Electron Notification API
### Priorité basse ### Priorité basse
- [ ] **Auth admin** : mot de passe pour accès dashboard
- [ ] **Thème toggle** : dark/light mode - [ ] **Thème toggle** : dark/light mode
- [ ] **Auto-update** : electron-updater pour mises à jour - [ ] **Auto-update** : electron-updater pour mises à jour
- [ ] **I18n** : français/anglais
### Technique ### Technique
- [ ] **Tests** : Spectron ou Playwright pour Electron - [ ] **Tests** : Spectron ou Playwright pour Electron
@@ -326,18 +361,21 @@ electron/
│ # - Spawn serveur │ # - Spawn serveur
│ # - IPC handlers │ # - IPC handlers
│ # - Window management │ # - Window management
│ # - Setup SSL au premier lancement
├── preload.js # IPC Bridge sécurisé ├── preload.js # IPC Bridge sécurisé
│ # - contextBridge │ # - contextBridge
│ # - Expose electronAPI │ # - Expose electronAPI
├── setup-helper.js # Installation auto mkcert + génération certificats
│ # - Détection IP réseau
├── package.json # Config Electron + electron-builder ├── package.json # Config Electron + electron-builder
└── ui/ # Renderer Process (Frontend) └── ui/ # Renderer Process (Frontend)
├── index.html # Structure UI ├── index.html # Structure UI
├── styles.css # Styles (dark theme) ├── styles.css # Styles (dark theme)
── app.js # Logic frontend ── app.js # Logic frontend (QR code reçu via IPC en data URL)
└── qrcode.min.js # QR Code library
``` ```
### Communication IPC ### Communication IPC
@@ -397,4 +435,4 @@ Même licence que PTT Live (MIT)
--- ---
**Version** : 0.3.0 **Version** : 0.3.0
**Dernière mise à jour** : 2026-06-19 **Dernière mise à jour** : 2026-06-30
+7 -7
View File
@@ -100,17 +100,17 @@ function App() {
const data = await response.json(); const data = await response.json();
// En mode dev (HTTPS via Vite), utiliser le proxy WebSocket // Si HTTPS, utiliser le proxy WebSocket (résout mixed content)
// En mode prod (HTTP direct), utiliser l'URL LiveKit directement // Sinon utiliser l'URL LiveKit directement
let livekitUrl = data.url; let livekitUrl = data.url;
if (import.meta.env.DEV && window.location.protocol === 'https:') { if (window.location.protocol === 'https:') {
// Mode dev avec Vite : utiliser le proxy WSS // HTTPS : utiliser le proxy WSS (wss://host:port/livekit)
livekitUrl = `${window.location.protocol}//${window.location.host}/livekit`; livekitUrl = `wss://${window.location.host}/livekit`;
console.log('🔒 Mode HTTPS : utilisation proxy WebSocket');
} }
console.log('🔗 Connexion LiveKit:', livekitUrl); console.log('🔗 Connexion LiveKit:', livekitUrl);
console.log('📝 Mode:', import.meta.env.DEV ? 'dev' : 'prod');
// Se connecter à LiveKit avec les canaux virtuels // Se connecter à LiveKit avec les canaux virtuels
await connect(livekitUrl, data.token, data.virtualChannels || []); await connect(livekitUrl, data.token, data.virtualChannels || []);
@@ -154,7 +154,7 @@ function App() {
// Adapter l'URL LiveKit selon le protocole de la page // Adapter l'URL LiveKit selon le protocole de la page
let livekitUrl = data.url; let livekitUrl = data.url;
if (window.location.protocol === 'https:') { if (window.location.protocol === 'https:') {
livekitUrl = `${window.location.protocol}//${window.location.host}/livekit`; livekitUrl = `wss://${window.location.host}/livekit`;
} }
// Changer de room LiveKit avec les canaux virtuels du nouveau groupe // Changer de room LiveKit avec les canaux virtuels du nouveau groupe
+32 -2
View File
@@ -7,6 +7,8 @@ const { app, BrowserWindow, ipcMain, Menu, Tray, dialog } = require('electron');
const path = require('path'); const path = require('path');
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const http = require('http'); const http = require('http');
const https = require('https');
const QRCode = require('qrcode');
const setupHelper = require('./setup-helper'); const setupHelper = require('./setup-helper');
// État de l'application // État de l'application
@@ -17,7 +19,14 @@ let serverStarted = false;
let rendererReady = false; let rendererReady = false;
const SERVER_PORT = process.env.PORT || 3000; const SERVER_PORT = process.env.PORT || 3000;
const SERVER_URL = `http://localhost:${SERVER_PORT}`; // HTTPS activé par défaut (cohérent avec le setup mkcert automatique au premier
// lancement) ; ENABLE_HTTPS=false permet de revenir explicitement en HTTP
const ENABLE_HTTPS = process.env.ENABLE_HTTPS !== 'false';
const SERVER_PROTOCOL = ENABLE_HTTPS ? 'https' : 'http';
// 127.0.0.1 plutôt que localhost : le serveur n'écoute qu'en IPv4 (host: 0.0.0.0
// dans config.yaml), or le Node embarqué par Electron peut résoudre "localhost"
// en IPv6 (::1) en priorité, ce qui ferait échouer silencieusement le ping
const SERVER_URL = `${SERVER_PROTOCOL}://127.0.0.1:${SERVER_PORT}`;
const isDev = process.argv.includes('--dev'); const isDev = process.argv.includes('--dev');
/** /**
@@ -133,6 +142,7 @@ async function startServer() {
...process.env, ...process.env,
PORT: SERVER_PORT, PORT: SERVER_PORT,
USE_LOCAL_LIVEKIT: 'true', USE_LOCAL_LIVEKIT: 'true',
ENABLE_HTTPS: ENABLE_HTTPS ? 'true' : 'false',
NODE_ENV: isDev ? 'development' : 'production' NODE_ENV: isDev ? 'development' : 'production'
} }
}); });
@@ -289,7 +299,13 @@ async function stopServer() {
*/ */
async function pingServer() { async function pingServer() {
return new Promise((resolve) => { return new Promise((resolve) => {
http.get(`${SERVER_URL}/health`, (res) => { const client = ENABLE_HTTPS ? https : http;
// rejectUnauthorized: false : le cert mkcert est approuvé par le Keychain
// macOS (Safari/Chrome/Electron renderer), mais le module https de Node
// ne lit pas ce trust store et rejetterait sinon ce ping vers notre
// propre serveur local.
const options = ENABLE_HTTPS ? { rejectUnauthorized: false } : {};
client.get(`${SERVER_URL}/health`, options, (res) => {
let data = ''; let data = '';
res.on('data', (chunk) => { data += chunk; }); res.on('data', (chunk) => { data += chunk; });
res.on('end', () => { res.on('end', () => {
@@ -327,6 +343,7 @@ app.whenReady().then(async () => {
return { return {
running: health.success, running: health.success,
health: health.data, health: health.data,
error: health.error,
url: SERVER_URL url: SERVER_URL
}; };
}); });
@@ -335,6 +352,19 @@ app.whenReady().then(async () => {
return await pingServer(); return await pingServer();
}); });
ipcMain.handle('qrcode:generate', async (event, text) => {
try {
const dataUrl = await QRCode.toDataURL(text, { width: 256, margin: 2 });
return { success: true, dataUrl };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle('network:ip', async () => {
return setupHelper.getNetworkIP();
});
// Créer fenêtre // Créer fenêtre
createWindow(); createWindow();
createTray(); createTray();
+14
View File
@@ -5,8 +5,16 @@
const { contextBridge, ipcRenderer } = require('electron'); const { contextBridge, ipcRenderer } = require('electron');
// Même logique que dans main.js : doit rester synchronisé avec SERVER_URL
// (127.0.0.1 : le serveur n'écoute qu'en IPv4, voir le commentaire dans main.js)
const SERVER_PORT = process.env.PORT || 3000;
const ENABLE_HTTPS = process.env.ENABLE_HTTPS !== 'false';
const SERVER_URL = `${ENABLE_HTTPS ? 'https' : 'http'}://127.0.0.1:${SERVER_PORT}`;
// Exposer l'API au renderer de manière sécurisée // Exposer l'API au renderer de manière sécurisée
contextBridge.exposeInMainWorld('electronAPI', { contextBridge.exposeInMainWorld('electronAPI', {
serverUrl: SERVER_URL,
// Contrôle serveur // Contrôle serveur
server: { server: {
start: () => ipcRenderer.invoke('server:start'), start: () => ipcRenderer.invoke('server:start'),
@@ -23,6 +31,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
} }
}, },
// QR Code (généré côté Main Process, pas de dépendance CDN)
generateQRCode: (text) => ipcRenderer.invoke('qrcode:generate', text),
// IP réseau locale (même détection que pour les certificats mkcert)
getNetworkIP: () => ipcRenderer.invoke('network:ip'),
// Helpers // Helpers
platform: process.platform, platform: process.platform,
version: process.env.npm_package_version || '0.3.0' version: process.env.npm_package_version || '0.3.0'
+215 -29
View File
@@ -2,12 +2,23 @@
* PTT Live Desktop - Renderer Process Logic * PTT Live Desktop - Renderer Process Logic
*/ */
const API_BASE = 'http://localhost:3000'; const API_BASE = window.electronAPI?.serverUrl || 'http://localhost:3000';
// État global // État global
let serverRunning = false; let serverRunning = false;
let statsInterval = null; let statsInterval = null;
let logsBuffer = []; let logsBuffer = [];
let audioLevelsWS = null;
let audioLevelsData = {
inputs: {},
groups: {},
outputs: {},
routing: {
activeInputs: [],
activeGroups: [],
activeOutputs: []
}
};
// ========== Initialisation ========== // ========== Initialisation ==========
@@ -167,6 +178,9 @@ function updateServerStatus(running) {
// Démarrer le polling // Démarrer le polling
startStatsPolling(); startStatsPolling();
// Connecter WebSocket audio levels
connectAudioLevelsWS();
// Charger les données initiales // Charger les données initiales
loadInitialData(); loadInitialData();
} else { } else {
@@ -177,6 +191,13 @@ function updateServerStatus(running) {
// Arrêter le polling // Arrêter le polling
stopStatsPolling(); stopStatsPolling();
// Déconnecter WebSocket audio levels
disconnectAudioLevelsWS();
// QR code obsolète tant que le serveur est arrêté : revenir au placeholder
document.getElementById('qr-code').removeAttribute('src');
document.getElementById('client-url').textContent = '--';
} }
} }
@@ -345,31 +366,29 @@ async function generateQRCode() {
// Détecter l'IP réseau (depuis hostname ou config) // Détecter l'IP réseau (depuis hostname ou config)
const networkIP = await getNetworkIP(); const networkIP = await getNetworkIP();
const clientUrl = `https://${networkIP}:5173`; // Mode dev Vite // En prod (Electron), le client buildé est servi par le serveur Express
// lui-même (même port que l'API), pas par Vite (port 5173, dev only)
// API_BASE pointe sur 127.0.0.1 (loopback, pour le ping interne) :
// on ne réutilise que protocole + port, l'IP doit être celle du réseau local
const serverOrigin = new URL(API_BASE);
const clientUrl = `${serverOrigin.protocol}//${networkIP}:${serverOrigin.port}`;
document.getElementById('client-url').textContent = clientUrl; document.getElementById('client-url').textContent = clientUrl;
// Générer QR Code // Générer QR Code (rendu côté Main Process, pas de dépendance réseau/CDN)
const canvas = document.getElementById('qr-code'); const img = document.getElementById('qr-code');
if (canvas && window.QRCode) { if (img) {
QRCode.toCanvas(canvas, clientUrl, { const result = await window.electronAPI.generateQRCode(clientUrl);
width: 256, if (result.success) {
margin: 2, img.src = result.dataUrl;
color: { console.log('✅ QR Code généré');
dark: '#000000', } else {
light: '#ffffff' console.error('Erreur génération QR Code:', result.error);
} }
}, (error) => {
if (error) {
console.error('Erreur génération QR Code:', error);
} else {
console.log('✅ QR Code généré');
}
});
} }
} catch (error) { } catch (error) {
console.error('Erreur récupération URL:', error); console.error('Erreur récupération URL:', error);
document.getElementById('client-url').textContent = 'https://localhost:5173'; document.getElementById('client-url').textContent = API_BASE;
} }
// Bouton copier URL (setup une seule fois) // Bouton copier URL (setup une seule fois)
@@ -385,15 +404,12 @@ async function generateQRCode() {
} }
async function getNetworkIP() { async function getNetworkIP() {
// Méthode 1 : depuis l'API serveur (qui détecte déjà l'IP) // Détection via le Main Process (même logique que pour les certs mkcert) :
// /admin/config renvoie la valeur YAML brute ("AUTO"), jamais l'IP résolue,
// donc inutilisable ici.
try { try {
const config = await apiCall('/admin/config'); const ip = await window.electronAPI.getNetworkIP();
if (config && config.server && config.server.livekit && config.server.livekit.url) { if (ip) return ip;
const url = config.server.livekit.url;
// Extraire l'IP depuis ws://IP:7880
const match = url.match(/ws:\/\/([^:]+):/);
if (match) return match[1];
}
} catch (error) { } catch (error) {
console.error('Erreur détection IP:', error); console.error('Erreur détection IP:', error);
} }
@@ -475,7 +491,7 @@ async function loadViewData(view) {
await fetchGroups(); await fetchGroups();
break; break;
case 'monitoring': case 'monitoring':
// TODO: charger VU-mètres WebSocket renderVUMeters();
break; break;
case 'logs': case 'logs':
renderLogs(); renderLogs();
@@ -579,6 +595,176 @@ function formatUptime(seconds) {
return `${h}h ${m}m ${s}s`; return `${h}h ${m}m ${s}s`;
} }
// ========== WebSocket Audio Levels ==========
function connectAudioLevelsWS() {
if (audioLevelsWS && audioLevelsWS.readyState === WebSocket.OPEN) {
console.log('WebSocket audio-levels déjà connecté');
return;
}
const wsUrl = API_BASE.replace(/^http/, 'ws') + '/audio-levels';
console.log('Connexion WebSocket audio-levels...', wsUrl);
try {
audioLevelsWS = new WebSocket(wsUrl);
audioLevelsWS.onopen = () => {
console.log('WebSocket audio-levels connecté');
updateVUMetersStatus('Connecté');
};
audioLevelsWS.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
switch (message.type) {
case 'initial':
case 'levels':
audioLevelsData = message.data;
renderVUMeters();
break;
case 'pong':
break;
default:
console.warn('Message WebSocket inconnu:', message.type);
}
} catch (error) {
console.error('Erreur parsing message WebSocket:', error);
}
};
audioLevelsWS.onerror = (error) => {
console.error('Erreur WebSocket audio-levels:', error);
updateVUMetersStatus('Erreur de connexion');
};
audioLevelsWS.onclose = () => {
console.log('WebSocket audio-levels déconnecté');
audioLevelsWS = null;
updateVUMetersStatus('Déconnecté');
// Reconnexion automatique si serveur actif
if (serverRunning) {
setTimeout(() => {
connectAudioLevelsWS();
}, 3000);
}
};
// Ping périodique
const pingInterval = setInterval(() => {
if (audioLevelsWS && audioLevelsWS.readyState === WebSocket.OPEN) {
audioLevelsWS.send(JSON.stringify({ type: 'ping' }));
} else {
clearInterval(pingInterval);
}
}, 10000);
} catch (error) {
console.error('Erreur création WebSocket:', error);
updateVUMetersStatus('Erreur de connexion');
}
}
function disconnectAudioLevelsWS() {
if (audioLevelsWS) {
audioLevelsWS.close();
audioLevelsWS = null;
}
}
function updateVUMetersStatus(status) {
const container = document.getElementById('vu-meters');
if (!container) return;
const statusEl = container.querySelector('.vu-status');
if (statusEl) {
statusEl.textContent = `WebSocket: ${status}`;
statusEl.className = `vu-status ${status === 'Connecté' ? 'connected' : 'disconnected'}`;
}
}
function renderVUMeters() {
const container = document.getElementById('vu-meters');
if (!container) return;
const hasData =
Object.keys(audioLevelsData.inputs).length > 0 ||
Object.keys(audioLevelsData.groups).length > 0 ||
Object.keys(audioLevelsData.outputs).length > 0;
if (!hasData) {
container.innerHTML = `
<p class="vu-status">WebSocket: En attente de connexion...</p>
<p class="empty-state">Aucune donnée audio disponible</p>
`;
return;
}
let html = '<div class="vu-status connected">WebSocket: Connecté</div>';
// Inputs
if (Object.keys(audioLevelsData.inputs).length > 0) {
html += '<div class="vu-section"><h4>Entrées Audio</h4><div class="vu-grid">';
Object.entries(audioLevelsData.inputs).forEach(([channelId, data]) => {
html += renderVUMeter(channelId, data, 'input');
});
html += '</div></div>';
}
// Groups
if (Object.keys(audioLevelsData.groups).length > 0) {
html += '<div class="vu-section"><h4>Groupes</h4><div class="vu-grid">';
Object.entries(audioLevelsData.groups).forEach(([groupName, data]) => {
html += renderVUMeter(groupName, data, 'group');
});
html += '</div></div>';
}
// Outputs
if (Object.keys(audioLevelsData.outputs).length > 0) {
html += '<div class="vu-section"><h4>Sorties Audio</h4><div class="vu-grid">';
Object.entries(audioLevelsData.outputs).forEach(([channelId, data]) => {
html += renderVUMeter(channelId, data, 'output');
});
html += '</div></div>';
}
container.innerHTML = html;
}
function renderVUMeter(label, data, type) {
const { rms, peak, clipping } = data;
// Convertir dBFS en pourcentage pour la barre (0dB = 100%, -60dB = 0%)
const rmsPercent = Math.max(0, Math.min(100, ((rms + 60) / 60) * 100));
const peakPercent = Math.max(0, Math.min(100, ((peak * 60 - 60 + 60) / 60) * 100));
// Couleur selon le niveau
let barClass = 'vu-bar-green';
if (rms > -6) barClass = 'vu-bar-red';
else if (rms > -12) barClass = 'vu-bar-yellow';
const clippingClass = clipping ? 'vu-meter-clipping' : '';
return `
<div class="vu-meter ${clippingClass}">
<div class="vu-label">${escapeHtml(label)}</div>
<div class="vu-bar-container">
<div class="vu-bar ${barClass}" style="width: ${rmsPercent}%"></div>
<div class="vu-peak" style="left: ${peakPercent}%"></div>
</div>
<div class="vu-values">
<span class="vu-rms">${rms.toFixed(1)} dB</span>
${clipping ? '<span class="vu-clip">CLIP!</span>' : ''}
</div>
</div>
`;
}
function formatTime(isoString) { function formatTime(isoString) {
if (!isoString) return '--'; if (!isoString) return '--';
const date = new Date(isoString); const date = new Date(isoString);
+8 -3
View File
@@ -78,7 +78,13 @@
<div class="section"> <div class="section">
<h3>📱 Connexion rapide clients</h3> <h3>📱 Connexion rapide clients</h3>
<div class="qr-container"> <div class="qr-container">
<canvas id="qr-code" width="256" height="256"></canvas> <div class="qr-wrapper">
<img id="qr-code" width="256" height="256" alt="QR Code connexion" />
<div class="qr-placeholder" id="qr-placeholder">
<span class="qr-placeholder-icon">📷</span>
<span>En attente du démarrage du serveur</span>
</div>
</div>
<div class="qr-info"> <div class="qr-info">
<p><strong>URL clients :</strong></p> <p><strong>URL clients :</strong></p>
<p class="url-text" id="client-url">--</p> <p class="url-text" id="client-url">--</p>
@@ -180,8 +186,7 @@
</main> </main>
</div> </div>
<!-- QR Code Library --> <!-- Le QR Code est généré côté Main Process (lib qrcode Node), pas de dépendance CDN -->
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>
-2
View File
@@ -1,2 +0,0 @@
// Placeholder - QR Code sera généré via CDN
// En production, utiliser une lib locale ou CDN
+173
View File
@@ -230,11 +230,51 @@ body {
align-items: center; align-items: center;
} }
.qr-wrapper {
position: relative;
width: 256px;
height: 256px;
flex-shrink: 0;
}
#qr-code { #qr-code {
display: none;
width: 256px;
height: 256px;
border: 4px solid white; border: 4px solid white;
border-radius: 8px; border-radius: 8px;
} }
/* L'image n'a un attribut src qu'une fois le QR code généré */
#qr-code[src] {
display: block;
}
#qr-code[src] ~ .qr-placeholder {
display: none;
}
.qr-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
height: 100%;
border: 2px dashed var(--border-color);
border-radius: 8px;
color: var(--text-secondary);
text-align: center;
padding: 1rem;
box-sizing: border-box;
}
.qr-placeholder-icon {
font-size: 2.5rem;
opacity: 0.5;
}
.qr-info { .qr-info {
flex: 1; flex: 1;
} }
@@ -554,3 +594,136 @@ body {
opacity: 1; opacity: 1;
} }
} }
/* VU Meters */
.vu-meters {
padding: 1rem 0;
}
.vu-status {
font-size: 0.875rem;
padding: 0.5rem;
margin-bottom: 1rem;
border-radius: 4px;
background: var(--bg-tertiary);
text-align: center;
}
.vu-status.connected {
background: rgba(76, 175, 80, 0.2);
color: var(--accent-success);
}
.vu-status.disconnected {
background: rgba(244, 67, 54, 0.2);
color: var(--accent-error);
}
.vu-section {
margin-bottom: 2rem;
}
.vu-section h4 {
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.vu-grid {
display: grid;
gap: 0.75rem;
}
.vu-meter {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 0.75rem;
transition: border-color 0.2s;
}
.vu-meter-clipping {
border-color: var(--accent-error);
animation: pulseClipping 0.5s ease-in-out infinite;
}
@keyframes pulseClipping {
0%, 100% {
border-color: var(--accent-error);
}
50% {
border-color: transparent;
}
}
.vu-label {
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
color: var(--text-primary);
}
.vu-bar-container {
position: relative;
height: 24px;
background: var(--bg-tertiary);
border-radius: 3px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.vu-bar {
height: 100%;
transition: width 0.05s linear;
border-radius: 3px;
}
.vu-bar-green {
background: linear-gradient(to right, #4caf50, #66bb6a);
}
.vu-bar-yellow {
background: linear-gradient(to right, #ff9800, #ffa726);
}
.vu-bar-red {
background: linear-gradient(to right, #f44336, #e57373);
}
.vu-peak {
position: absolute;
top: 0;
width: 2px;
height: 100%;
background: #ffffff;
box-shadow: 0 0 4px rgba(255, 255, 255, 0.8);
transition: left 0.1s linear;
}
.vu-values {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.75rem;
}
.vu-rms {
color: var(--text-secondary);
}
.vu-clip {
color: var(--accent-error);
font-weight: bold;
animation: blinkClip 0.5s ease-in-out infinite;
}
@keyframes blinkClip {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
+45
View File
@@ -16,6 +16,7 @@ import configManager from './config/ConfigManager.js';
import audioBridgeManager from './bridge/AudioBridgeManager.js'; import audioBridgeManager from './bridge/AudioBridgeManager.js';
import AudioLevelsServer from './websocket/AudioLevelsServer.js'; import AudioLevelsServer from './websocket/AudioLevelsServer.js';
import { setGlobalLogLevel } from './utils/Logger.js'; import { setGlobalLogLevel } from './utils/Logger.js';
import httpProxy from 'http-proxy';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -378,6 +379,33 @@ apiRouter.get('/health', (req, res) => {
}); });
}); });
// Créer proxy WebSocket natif pour LiveKit (wss → ws)
const livekitProxy = httpProxy.createProxyServer({
target: 'http://localhost:7880',
ws: true,
changeOrigin: true
});
livekitProxy.on('error', (err, req, res) => {
log('error', `❌ Erreur proxy LiveKit: ${err.message}`);
if (res && res.writeHead) {
res.writeHead(502, { 'Content-Type': 'text/plain' });
res.end('Proxy error');
}
});
livekitProxy.on('proxyReqWs', (proxyReq, req, socket, options, head) => {
log('debug', `🔀 Proxy WebSocket: ${req.url} → ws://localhost:7880`);
});
// Proxy HTTP pour LiveKit (requêtes REST comme /rtc/validate)
app.use('/livekit', (req, res) => {
log('debug', `🔀 Proxy HTTP: ${req.originalUrl} → http://localhost:7880${req.url}`);
livekitProxy.web(req, res, {
target: 'http://localhost:7880'
});
});
// Monter le router API sous /api ET à la racine (rétrocompatibilité) // Monter le router API sous /api ET à la racine (rétrocompatibilité)
app.use('/api', apiRouter); app.use('/api', apiRouter);
app.use(apiRouter); // Routes accessibles aussi sans préfixe /api app.use(apiRouter); // Routes accessibles aussi sans préfixe /api
@@ -493,8 +521,25 @@ async function start() {
} }
// 2.5 Démarrer WebSocket Audio Levels (même port que l'API) // 2.5 Démarrer WebSocket Audio Levels (même port que l'API)
// noServer: true en interne, l'upgrade est dispatché ci-dessous
const audioLevelsServer = new AudioLevelsServer({ server }); const audioLevelsServer = new AudioLevelsServer({ server });
audioLevelsServer.start(); audioLevelsServer.start();
// 2.6 Dispatcher unique pour les upgrades WebSocket du port HTTP/HTTPS
// (proxy LiveKit et audio-levels partagent le même serveur, donc le même
// événement 'upgrade' : un seul listener doit trancher par chemin)
server.on('upgrade', (req, socket, head) => {
if (req.url.startsWith('/livekit')) {
req.url = req.url.replace(/^\/livekit/, '');
livekitProxy.ws(req, socket, head);
} else if (req.url.startsWith('/audio-levels')) {
audioLevelsServer.handleUpgrade(req, socket, head);
} else {
log('warn', `⚠️ Unknown WebSocket path: ${req.url}`);
socket.destroy();
}
});
const wsProtocol = ENABLE_HTTPS ? 'wss' : 'ws'; const wsProtocol = ENABLE_HTTPS ? 'wss' : 'ws';
log('info', `✓ WebSocket Audio Levels démarré sur ${wsProtocol}://${SERVER_HOST}:${SERVER_PORT}`); log('info', `✓ WebSocket Audio Levels démarré sur ${wsProtocol}://${SERVER_HOST}:${SERVER_PORT}`);
+2
View File
@@ -22,6 +22,8 @@
"@livekit/rtc-node": "^0.13.28", "@livekit/rtc-node": "^0.13.28",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^4.19.2", "express": "^4.19.2",
"http-proxy": "^1.18.1",
"http-proxy-middleware": "^4.1.1",
"livekit-server-sdk": "^2.6.0", "livekit-server-sdk": "^2.6.0",
"opusscript": "^0.1.1", "opusscript": "^0.1.1",
"qrcode-terminal": "^0.12.0", "qrcode-terminal": "^0.12.0",
+13 -1
View File
@@ -91,9 +91,11 @@ export class AudioLevelsServer extends EventEmitter {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
// Si un serveur HTTP est fourni, utiliser le même port (upgrade HTTP → WebSocket) // Si un serveur HTTP est fourni, utiliser le même port (upgrade HTTP → WebSocket)
// noServer: true car l'upgrade est dispatché manuellement par server/index.js
// (un seul listener 'upgrade' partagé avec le proxy LiveKit, voir handleUpgrade())
// Sinon, créer un serveur WebSocket standalone sur son propre port // Sinon, créer un serveur WebSocket standalone sur son propre port
const wsOptions = this.options.server const wsOptions = this.options.server
? { server: this.options.server, path: '/audio-levels' } ? { noServer: true }
: { port: this.options.port }; : { port: this.options.port };
this.wss = new WebSocketServer(wsOptions); this.wss = new WebSocketServer(wsOptions);
@@ -125,6 +127,16 @@ export class AudioLevelsServer extends EventEmitter {
}); });
} }
/**
* Complète l'upgrade WebSocket pour une requête déjà identifiée comme
* ciblant ce serveur (voir le dispatcher 'upgrade' dans server/index.js)
*/
handleUpgrade(req, socket, head) {
this.wss.handleUpgrade(req, socket, head, (ws) => {
this.wss.emit('connection', ws, req);
});
}
/** /**
* Gère une nouvelle connexion client * Gère une nouvelle connexion client
*/ */