feat(chat): desktop usando Convex WS direto e fallback WS dedicado

This commit is contained in:
esdrasrenan 2025-12-09 01:01:54 -03:00
parent 8db7c3c810
commit a8f5ff9d51
14 changed files with 735 additions and 458 deletions

View file

@ -68,7 +68,6 @@ dependencies = [
"once_cell",
"parking_lot",
"reqwest",
"reqwest-eventsource",
"serde",
"serde_json",
"sha2",
@ -83,6 +82,8 @@ dependencies = [
"tauri-plugin-updater",
"thiserror 1.0.69",
"tokio",
"tokio-tungstenite",
"url",
"winreg",
]
@ -762,6 +763,12 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "data-encoding"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
[[package]]
name = "deranged"
version = "0.5.4"
@ -1023,17 +1030,6 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "eventsource-stream"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab"
dependencies = [
"futures-core",
"nom",
"pin-project-lite",
]
[[package]]
name = "fastrand"
version = "2.3.0"
@ -1208,12 +1204,6 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
[[package]]
name = "futures-timer"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
[[package]]
name = "futures-util"
version = "0.3.31"
@ -1704,12 +1694,12 @@ dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"rustls 0.23.32",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tokio-rustls 0.26.4",
"tower-service",
"webpki-roots",
"webpki-roots 1.0.3",
]
[[package]]
@ -2227,12 +2217,6 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "minisign-verify"
version = "0.2.4"
@ -2336,16 +2320,6 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "notify-rust"
version = "4.11.7"
@ -3146,7 +3120,7 @@ dependencies = [
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"rustls 0.23.32",
"socket2",
"thiserror 2.0.17",
"tokio",
@ -3166,7 +3140,7 @@ dependencies = [
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
"rustls 0.23.32",
"rustls-pki-types",
"slab",
"thiserror 2.0.17",
@ -3431,14 +3405,14 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls 0.23.32",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-rustls 0.26.4",
"tokio-util",
"tower",
"tower-http",
@ -3448,23 +3422,7 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots",
]
[[package]]
name = "reqwest-eventsource"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "632c55746dbb44275691640e7b40c907c16a2dc1a5842aa98aaec90da6ec6bde"
dependencies = [
"eventsource-stream",
"futures-core",
"futures-timer",
"mime",
"nom",
"pin-project-lite",
"reqwest",
"thiserror 1.0.69",
"webpki-roots 1.0.3",
]
[[package]]
@ -3525,6 +3483,20 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432"
dependencies = [
"log",
"ring",
"rustls-pki-types",
"rustls-webpki 0.102.8",
"subtle",
"zeroize",
]
[[package]]
name = "rustls"
version = "0.23.32"
@ -3534,7 +3506,7 @@ dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"rustls-webpki 0.103.7",
"subtle",
"zeroize",
]
@ -3549,6 +3521,17 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.102.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustls-webpki"
version = "0.103.7"
@ -3836,6 +3819,17 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
@ -4669,16 +4663,43 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "tokio-rustls"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f"
dependencies = [
"rustls 0.22.4",
"rustls-pki-types",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"rustls 0.23.32",
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38"
dependencies = [
"futures-util",
"log",
"rustls 0.22.4",
"rustls-pki-types",
"tokio",
"tokio-rustls 0.25.0",
"tungstenite",
"webpki-roots 0.26.11",
]
[[package]]
name = "tokio-util"
version = "0.7.16"
@ -4892,6 +4913,27 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.8.5",
"rustls 0.22.4",
"rustls-pki-types",
"sha1",
"thiserror 1.0.69",
"url",
"utf-8",
]
[[package]]
name = "typeid"
version = "1.0.3"
@ -5252,6 +5294,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.3",
]
[[package]]
name = "webpki-roots"
version = "1.0.3"

View file

@ -30,7 +30,6 @@ serde_json = "1"
sysinfo = { version = "0.31", default-features = false, features = ["multithread", "network", "system", "disk"] }
get_if_addrs = "0.5"
reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking"], default-features = false }
reqwest-eventsource = "0.6"
futures-util = "0.3"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
once_cell = "1.19"
@ -40,6 +39,8 @@ parking_lot = "0.12"
hostname = "0.4"
base64 = "0.22"
sha2 = "0.10"
tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"] }
url = "2.5"
[target.'cfg(windows)'.dependencies]
winreg = "0.55"

View file

@ -1,21 +1,23 @@
//! Modulo de Chat em Tempo Real
//!
//! Este modulo implementa o sistema de chat entre agentes (dashboard web)
//! e clientes (Raven desktop). Usa SSE (Server-Sent Events) como metodo
//! e clientes (Raven desktop). Usa WebSocket como metodo
//! primario para atualizacoes em tempo real, com fallback para HTTP polling.
use futures_util::StreamExt;
use futures_util::{StreamExt, SinkExt};
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use reqwest::Client;
use reqwest_eventsource::{Event, EventSource};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tauri::async_runtime::JoinHandle;
use tauri::{Emitter, Manager, WebviewWindowBuilder, WebviewUrl};
use tauri_plugin_notification::NotificationExt;
use tokio_tungstenite::{connect_async, tungstenite::protocol::Message};
use url::Url;
// ============================================================================
// TYPES
@ -399,18 +401,24 @@ pub async fn upload_file(
}
// ============================================================================
// SSE (Server-Sent Events) TYPES
// WebSocket TYPES
// ============================================================================
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SseUpdateEvent {
struct WsUpdateEvent {
has_active_sessions: bool,
sessions: Vec<ChatSessionSummary>,
total_unread: u32,
ts: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct WsEnvelope {
event: String,
data: serde_json::Value,
}
// ============================================================================
// CHAT RUNTIME
// ============================================================================
@ -432,7 +440,7 @@ pub struct ChatRuntime {
inner: Arc<Mutex<Option<ChatPollerHandle>>>,
last_sessions: Arc<Mutex<Vec<ChatSession>>>,
last_unread_count: Arc<Mutex<u32>>,
is_using_sse: Arc<AtomicBool>,
is_using_ws: Arc<AtomicBool>,
}
impl ChatRuntime {
@ -441,17 +449,17 @@ impl ChatRuntime {
inner: Arc::new(Mutex::new(None)),
last_sessions: Arc::new(Mutex::new(Vec::new())),
last_unread_count: Arc::new(Mutex::new(0)),
is_using_sse: Arc::new(AtomicBool::new(false)),
is_using_ws: Arc::new(AtomicBool::new(false)),
}
}
/// Retorna true se esta usando SSE, false se usando polling HTTP
pub fn is_using_sse(&self) -> bool {
self.is_using_sse.load(Ordering::Relaxed)
/// Retorna true se esta usando WebSocket, false se usando polling HTTP
pub fn is_using_ws(&self) -> bool {
self.is_using_ws.load(Ordering::Relaxed)
}
/// Inicia o sistema de atualizacoes de chat.
/// Tenta SSE primeiro, com fallback automatico para HTTP polling.
/// Tenta WebSocket primeiro, com fallback automatico para HTTP polling.
pub fn start_polling(
&self,
base_url: String,
@ -463,7 +471,7 @@ impl ChatRuntime {
return Err("URL base invalida".to_string());
}
// Para polling/SSE existente
// Para polling/WS existente
{
let mut guard = self.inner.lock();
if let Some(handle) = guard.take() {
@ -477,12 +485,12 @@ impl ChatRuntime {
let token_clone = token.clone();
let last_sessions = self.last_sessions.clone();
let last_unread_count = self.last_unread_count.clone();
let is_using_sse = self.is_using_sse.clone();
let is_using_ws = self.is_using_ws.clone();
let join_handle = tauri::async_runtime::spawn(async move {
crate::log_info!("Chat iniciando (tentando SSE primeiro)");
crate::log_info!("Chat iniciando (tentando WebSocket primeiro)");
// Loop principal com SSE + fallback para polling
// Loop principal com WebSocket + fallback para polling
loop {
// Verificar se deve parar
if stop_clone.load(Ordering::Relaxed) {
@ -490,14 +498,14 @@ impl ChatRuntime {
break;
}
// Tentar SSE primeiro
let sse_result = run_sse_loop(
// Tentar WebSocket primeiro
let ws_result = run_ws_loop(
&base_clone,
&token_clone,
&app,
&last_sessions,
&last_unread_count,
&is_using_sse,
&is_using_ws,
&stop_clone,
)
.await;
@ -508,16 +516,16 @@ impl ChatRuntime {
break;
}
match sse_result {
match ws_result {
Ok(()) => {
// SSE encerrado normalmente (stop signal)
// WS encerrado normalmente (stop signal)
break;
}
Err(e) => {
crate::log_warn!("SSE falhou: {e}. Usando polling HTTP...");
is_using_sse.store(false, Ordering::Relaxed);
crate::log_warn!("WebSocket falhou: {e}. Usando polling HTTP...");
is_using_ws.store(false, Ordering::Relaxed);
// Executar polling HTTP por 5 minutos, depois tentar SSE novamente
// Executar polling HTTP por 5 minutos, depois tentar WebSocket novamente
let poll_duration = Duration::from_secs(300); // 5 minutos
let poll_result = run_polling_loop(
&base_clone,
@ -534,7 +542,7 @@ impl ChatRuntime {
break;
}
crate::log_info!("Tentando reconectar SSE...");
crate::log_info!("Tentando reconectar WebSocket...");
}
}
}
@ -554,7 +562,7 @@ impl ChatRuntime {
if let Some(handle) = guard.take() {
handle.stop();
}
self.is_using_sse.store(false, Ordering::Relaxed);
self.is_using_ws.store(false, Ordering::Relaxed);
}
pub fn get_sessions(&self) -> Vec<ChatSession> {
@ -563,90 +571,138 @@ impl ChatRuntime {
}
// ============================================================================
// SSE LOOP
// WS LOOP
// ============================================================================
async fn run_sse_loop(
async fn run_ws_loop(
base_url: &str,
token: &str,
app: &tauri::AppHandle,
last_sessions: &Arc<Mutex<Vec<ChatSession>>>,
last_unread_count: &Arc<Mutex<u32>>,
is_using_sse: &Arc<AtomicBool>,
is_using_ws: &Arc<AtomicBool>,
stop_flag: &Arc<AtomicBool>,
) -> Result<(), String> {
let sse_url = format!("{}/api/machines/chat/stream?token={}", base_url, token);
crate::log_info!("Conectando SSE: {}", sse_url);
let ws_url = build_ws_url(base_url, token)?;
crate::log_info!("Conectando WebSocket: {}", ws_url);
let request = CHAT_CLIENT.get(&sse_url);
let mut es = EventSource::new(request).map_err(|e| format!("Falha ao criar EventSource: {e}"))?;
let (ws_stream, _) = connect_async(ws_url)
.await
.map_err(|e| format!("Falha ao conectar WebSocket: {e}"))?;
let (mut write, mut read) = ws_stream.split();
is_using_sse.store(true, Ordering::Relaxed);
crate::log_info!("SSE conectado com sucesso");
// Ativar ping periódico para manter conexão viva
let mut heartbeat = tokio::time::interval(Duration::from_secs(45));
is_using_ws.store(true, Ordering::Relaxed);
crate::log_info!("WebSocket conectado com sucesso");
loop {
// Verificar stop flag periodicamente
if stop_flag.load(Ordering::Relaxed) {
crate::log_info!("SSE encerrado por stop flag");
crate::log_info!("WebSocket encerrado por stop flag");
let _ = write.send(Message::Close(None)).await;
return Ok(());
}
// Usar timeout para poder verificar stop flag
let event = tokio::time::timeout(Duration::from_secs(1), es.next()).await;
match event {
Ok(Some(Ok(Event::Open))) => {
crate::log_info!("SSE: conexao aberta");
tokio::select! {
_ = heartbeat.tick() => {
let _ = write.send(Message::Ping(Vec::new())).await;
}
Ok(Some(Ok(Event::Message(msg)))) => {
let event_type = msg.event.as_str();
match event_type {
"connected" => {
crate::log_info!("SSE: evento connected recebido");
}
"heartbeat" => {
// Ignorar heartbeats silenciosamente
}
"update" => {
// Processar update de chat
if let Ok(update) = serde_json::from_str::<SseUpdateEvent>(&msg.data) {
process_chat_update(
msg = read.next() => {
match msg {
Some(Ok(Message::Text(text))) => {
if let Ok(parsed) = serde_json::from_str::<WsEnvelope>(&text) {
handle_ws_event(
parsed,
base_url,
token,
app,
last_sessions,
last_unread_count,
update.has_active_sessions,
update.total_unread,
)
.await;
).await?;
} else {
crate::log_warn!("WebSocket: payload inválido");
}
}
"error" => {
crate::log_warn!("SSE: erro recebido do servidor: {}", msg.data);
return Err(format!("Erro SSE do servidor: {}", msg.data));
Some(Ok(Message::Close(_))) => {
crate::log_info!("WebSocket: conexão encerrada pelo servidor");
return Err("WebSocket fechado pelo servidor".to_string());
}
_ => {
crate::log_info!("SSE: evento desconhecido: {}", event_type);
Some(Ok(_)) => {
// Ignorar outros frames
}
Some(Err(e)) => {
crate::log_warn!("WebSocket erro: {e}");
return Err(format!("Erro WebSocket: {e}"));
}
None => {
crate::log_info!("WebSocket: stream encerrado");
return Err("WebSocket encerrado".to_string());
}
}
}
Ok(Some(Err(e))) => {
crate::log_warn!("SSE erro: {e}");
return Err(format!("Erro SSE: {e}"));
}
Ok(None) => {
crate::log_info!("SSE: stream encerrado");
return Err("Stream SSE encerrado".to_string());
}
Err(_) => {
// Timeout - continuar loop para verificar stop flag
}
}
}
}
fn build_ws_url(base_url: &str, token: &str) -> Result<Url, String> {
let trimmed = base_url.trim_end_matches('/');
let mut ws_url = if trimmed.starts_with("https://") {
trimmed.replacen("https://", "wss://", 1)
} else if trimmed.starts_with("http://") {
trimmed.replacen("http://", "ws://", 1)
} else {
format!("wss://{}", trimmed)
};
ws_url.push_str("/chat-ws?token=");
ws_url.push_str(token);
Url::parse(&ws_url).map_err(|e| format!("URL WS inválida: {e}"))
}
async fn handle_ws_event(
envelope: WsEnvelope,
base_url: &str,
token: &str,
app: &tauri::AppHandle,
last_sessions: &Arc<Mutex<Vec<ChatSession>>>,
last_unread_count: &Arc<Mutex<u32>>,
) -> Result<(), String> {
match envelope.event.as_str() {
"connected" => {
crate::log_info!("WebSocket: conectado");
}
"heartbeat" => {
// noop
}
"update" => {
let update: WsUpdateEvent = serde_json::from_value(envelope.data)
.map_err(|e| format!("Payload update inválido: {e}"))?;
process_chat_update(
base_url,
token,
app,
last_sessions,
last_unread_count,
update.has_active_sessions,
update.total_unread,
)
.await;
}
"error" => {
let message = envelope
.data
.get("message")
.and_then(Value::as_str)
.unwrap_or("Erro WebSocket");
return Err(message.to_string());
}
_ => {
crate::log_info!("WebSocket: evento desconhecido {}", envelope.event);
}
}
Ok(())
}
// ============================================================================
// HTTP POLLING LOOP (FALLBACK)
// ============================================================================

View file

@ -245,8 +245,8 @@ fn stop_chat_polling(state: tauri::State<ChatRuntime>) -> Result<(), String> {
}
#[tauri::command]
fn is_chat_using_sse(state: tauri::State<ChatRuntime>) -> bool {
state.is_using_sse()
fn is_chat_using_ws(state: tauri::State<ChatRuntime>) -> bool {
state.is_using_ws()
}
#[tauri::command]
@ -492,7 +492,7 @@ pub fn run() {
// Chat commands
start_chat_polling,
stop_chat_polling,
is_chat_using_sse,
is_chat_using_ws,
get_chat_sessions,
fetch_chat_sessions,
fetch_chat_messages,
@ -632,7 +632,7 @@ fn setup_tray(app: &tauri::AppHandle) -> tauri::Result<()> {
async fn try_start_background_agent(
app: &tauri::AppHandle,
agent_runtime: AgentRuntime,
chat_runtime: ChatRuntime,
_chat_runtime: ChatRuntime,
) -> Result<(), String> {
log_info!("Verificando credenciais salvas para iniciar agente...");
@ -687,18 +687,7 @@ async fn try_start_background_agent(
)
.map_err(|e| format!("Falha ao iniciar heartbeat: {e}"))?;
log_info!("Agente iniciado com sucesso em background");
// Iniciar chat polling
if let Err(e) = chat_runtime.start_polling(
api_base_url.to_string(),
token.to_string(),
app.clone(),
) {
log_warn!("Falha ao iniciar chat polling: {e}");
} else {
log_info!("Chat polling iniciado com sucesso");
}
log_info!("Agente iniciado com sucesso em background (chat via Convex WebSocket no frontend)");
Ok(())
}

View file

@ -32,7 +32,7 @@
],
"dialog": true,
"active": true,
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDE5MTMxRTQwODA1NEFCRjAKUldUd3ExU0FRQjRUR2VqcHBNdXhBMUV3WlM2cFA4dmNnNEhtMUJ2a3VVWVlTQnoxbEo5YUtlUTMK"
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDZDRTBFNkY1NUQ3QzU0QkEKUldTNlZIeGQ5ZWJnYk5mY0J4aWRlb0dRdVZ4TGpBSUZXMnRVUFhmdmlLT0tlY084UjJQUHFWWUkK"
},
"deep-link": {
"desktop": {