From e0bb6bb80fbb906e5a8af3f381e1ae79b46556c0 Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Tue, 11 Nov 2025 20:26:29 -0300 Subject: [PATCH] feat: event-driven rustdesk sync --- apps/desktop/.env.example | 4 + apps/desktop/src-tauri/src/lib.rs | 47 +++++-- apps/desktop/src-tauri/src/rustdesk.rs | 124 ++++++++++++----- apps/desktop/src/main.tsx | 184 ++++++++++++++++++------- 4 files changed, 263 insertions(+), 96 deletions(-) diff --git a/apps/desktop/.env.example b/apps/desktop/.env.example index e88b217..494acf1 100644 --- a/apps/desktop/.env.example +++ b/apps/desktop/.env.example @@ -9,6 +9,10 @@ VITE_APP_URL=http://localhost:3000 # Se não definir, cai no mesmo valor de VITE_APP_URL VITE_API_BASE_URL= +# RustDesk provisioning (opcionais; se vazios, o app usa o TOML padrão embutido) +VITE_RUSTDESK_CONFIG_STRING= +VITE_RUSTDESK_DEFAULT_PASSWORD= + # Opcional: IP do host para desenvolvimento com HMR fora do localhost # Ex.: 192.168.0.10 TAURI_DEV_HOST= diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 7aaafcd..cf0ad5e 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ mod agent; mod rustdesk; use agent::{collect_inventory_plain, collect_profile, AgentRuntime, MachineProfile}; +use tauri::Emitter; use tauri_plugin_store::Builder as StorePluginBuilder; #[derive(Debug, serde::Serialize)] @@ -12,6 +13,7 @@ pub struct RustdeskProvisioningResult { pub password: String, pub installed_version: Option, pub updated: bool, + pub last_provisioned_at: i64, } #[tauri::command] @@ -50,22 +52,45 @@ fn open_devtools(window: tauri::WebviewWindow) -> Result<(), String> { } #[tauri::command] -async fn provision_rustdesk(machine_id: Option) -> Result { - let machine_id = machine_id - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| "Informe o identificador da máquina para provisionar o RustDesk.".to_string())?; - tauri::async_runtime::spawn_blocking(move || run_rustdesk_provision(machine_id)) - .await - .map_err(|error| error.to_string())? +async fn ensure_rustdesk_and_emit( + app: tauri::AppHandle, + config_string: Option, + password: Option, + machine_id: Option, +) -> Result { + let result = tauri::async_runtime::spawn_blocking(move || { + run_rustdesk_ensure(config_string, password, machine_id) + }) + .await + .map_err(|error| error.to_string())??; + + if let Err(error) = app.emit("raven://remote-access/provisioned", &result) { + eprintln!("[rustdesk] falha ao emitir evento raven://remote-access/provisioned: {error}"); + } + + Ok(result) } #[cfg(target_os = "windows")] -fn run_rustdesk_provision(machine_id: String) -> Result { - rustdesk::provision(&machine_id).map_err(|error| error.to_string()) +fn run_rustdesk_ensure( + config_string: Option, + password: Option, + machine_id: Option, +) -> Result { + rustdesk::ensure_rustdesk( + config_string.as_deref(), + password.as_deref(), + machine_id.as_deref(), + ) + .map_err(|error| error.to_string()) } #[cfg(not(target_os = "windows"))] -fn run_rustdesk_provision(_machine_id: String) -> Result { +fn run_rustdesk_ensure( + _config_string: Option, + _password: Option, + _machine_id: Option, +) -> Result { Err("Provisionamento automático do RustDesk está disponível apenas no Windows.".to_string()) } @@ -83,7 +108,7 @@ pub fn run() { start_machine_agent, stop_machine_agent, open_devtools, - provision_rustdesk + ensure_rustdesk_and_emit ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/apps/desktop/src-tauri/src/rustdesk.rs b/apps/desktop/src-tauri/src/rustdesk.rs index 59f44c6..3de5069 100644 --- a/apps/desktop/src-tauri/src/rustdesk.rs +++ b/apps/desktop/src-tauri/src/rustdesk.rs @@ -1,7 +1,7 @@ #![cfg(target_os = "windows")] use crate::RustdeskProvisioningResult; -use chrono::Local; +use chrono::{Local, Utc}; use once_cell::sync::Lazy; use parking_lot::Mutex; use reqwest::blocking::Client; @@ -53,9 +53,13 @@ struct ReleaseResponse { assets: Vec, } -pub fn provision(machine_id: &str) -> Result { +pub fn ensure_rustdesk( + config_string: Option<&str>, + password_override: Option<&str>, + machine_id: Option<&str>, +) -> Result { let _guard = PROVISION_MUTEX.lock(); - log_event("Iniciando provisionamento do RustDesk"); + log_event("Iniciando preparo do RustDesk"); let exe_path = detect_executable_path(); let (installed_version, freshly_installed) = ensure_installed(&exe_path)?; @@ -65,26 +69,57 @@ pub fn provision(machine_id: &str) -> Result { + log_event(&format!("ID determinístico definido: {custom}")); + Some(custom) + } + Err(error) => { + log_event(&format!("Falha ao definir ID determinístico: {error}")); + None + } + } + } else { + None + }; if let Err(error) = ensure_service_running() { log_event(&format!("Falha ao reiniciar serviço do RustDesk: {error}")); @@ -92,21 +127,26 @@ pub fn provision(machine_id: &str) -> Result value, + Err(error) => { + log_event(&format!("Falha ao obter ID após múltiplas tentativas: {error}")); + match read_remote_id_from_profiles().or_else(|| custom_id.clone()) { + Some(value) => { + log_event(&format!("ID obtido via arquivos de perfil: {value}")); + value + } + None => return Err(error), } - fallback - }) - .unwrap_or_else(|| custom_id.clone()); - if reported_id != custom_id { - log_event(&format!( - "ID retornado difere do determinístico ({custom_id}) -> aplicando {reported_id}" - )); + } + }; + + if let Some(expected) = custom_id.as_ref() { + if expected != &reported_id { + log_event(&format!( + "ID retornado difere do determinístico ({expected}) -> aplicando {reported_id}" + )); + } } ensure_remote_id_files(&reported_id); @@ -114,9 +154,10 @@ pub fn provision(machine_id: &str) -> Result Result<(), RustdeskError Ok(()) } -fn set_password(exe_path: &Path) -> Result<(), RustdeskError> { - run_with_args(exe_path, &["--password", DEFAULT_PASSWORD]) +fn set_password(exe_path: &Path, secret: &str) -> Result<(), RustdeskError> { + run_with_args(exe_path, &["--password", secret]) } fn set_custom_id(exe_path: &Path, machine_id: &str) -> Result { @@ -309,6 +350,25 @@ fn run_sc(args: &[&str]) -> Result<(), RustdeskError> { Ok(()) } +fn query_id_with_retries(exe_path: &Path, attempts: usize) -> Result { + let mut last_error: Option = None; + for attempt in 0..attempts { + match query_id(exe_path) { + Ok(value) if !value.trim().is_empty() => return Ok(value), + Ok(_) => { + last_error = Some(RustdeskError::MissingId); + } + Err(error) => { + last_error = Some(error); + } + } + if attempt + 1 < attempts { + thread::sleep(Duration::from_millis(800)); + } + } + Err(last_error.unwrap_or(RustdeskError::MissingId)) +} + fn query_id(exe_path: &Path) -> Result { let output = Command::new(exe_path) .arg("--get-id") diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index b7d652d..4ff85a0 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -2,8 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { createRoot } from "react-dom/client" import { invoke } from "@tauri-apps/api/core" +import { listen } from "@tauri-apps/api/event" import { Store } from "@tauri-apps/plugin-store" -import { appLocalDataDir, executableDir, join } from "@tauri-apps/api/path" +import { appLocalDataDir, join } from "@tauri-apps/api/path" import { ExternalLink, Eye, EyeOff, Loader2, RefreshCw } from "lucide-react" import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/ui/tabs" import { cn } from "./lib/utils" @@ -82,10 +83,10 @@ type RustdeskProvisioningResult = { password: string installedVersion?: string | null updated: boolean + lastProvisionedAt: number } type RustdeskInfo = RustdeskProvisioningResult & { - lastProvisionedAt: number lastSyncedAt?: number | null lastError?: string | null } @@ -96,6 +97,8 @@ declare global { interface ImportMetaEnv { readonly VITE_APP_URL?: string readonly VITE_API_BASE_URL?: string + readonly VITE_RUSTDESK_CONFIG_STRING?: string + readonly VITE_RUSTDESK_DEFAULT_PASSWORD?: string } interface ImportMeta { readonly env: ImportMetaEnv } } @@ -111,6 +114,8 @@ function normalizeUrl(value?: string | null, fallback = DEFAULT_APP_URL) { const appUrl = normalizeUrl(import.meta.env.VITE_APP_URL, DEFAULT_APP_URL) const apiBaseUrl = normalizeUrl(import.meta.env.VITE_API_BASE_URL, appUrl) +const RUSTDESK_CONFIG_STRING = import.meta.env.VITE_RUSTDESK_CONFIG_STRING?.trim() || null +const RUSTDESK_DEFAULT_PASSWORD = import.meta.env.VITE_RUSTDESK_DEFAULT_PASSWORD?.trim() || null const RUSTDESK_SYNC_INTERVAL_MS = 60 * 60 * 1000 // 1h const TOKEN_SELF_HEAL_DEBOUNCE_MS = 30 * 1000 @@ -157,17 +162,9 @@ function buildRemoteAccessSnapshot(info: RustdeskInfo | null) { } async function loadStore(): Promise { - // Tenta usar uma pasta "data" ao lado do executável (ex.: C:\Raven\data) - try { - const exeDir = await executableDir() - const storePath = await join(exeDir, "data", STORE_FILENAME) - return await Store.load(storePath) - } catch { - // Fallback: AppData local do usuário - const appData = await appLocalDataDir() - const storePath = await join(appData, STORE_FILENAME) - return await Store.load(storePath) - } + const appData = await appLocalDataDir() + const storePath = await join(appData, STORE_FILENAME) + return await Store.load(storePath) } async function readToken(store: Store): Promise { @@ -629,6 +626,7 @@ useEffect(() => { rustdeskInfoRef.current = rustdeskInfo }, [rustdeskInfo]) + useEffect(() => { if (!store || !config) return const email = collabEmail.trim() @@ -736,69 +734,124 @@ const resolvedAppUrl = useMemo(() => { return normalized }, [config?.appUrl]) - const syncRustdeskAccess = useCallback( - async (machineToken: string, info: RustdeskInfo, allowRetry = true) => { - if (!store || !machineToken) return + const syncRemoteAccessNow = useCallback( + async (info: RustdeskInfo, allowRetry = true) => { + if (!store) return const payload = buildRemoteAccessPayload(info) if (!payload) return - try { + + const resolveToken = async (allowHeal: boolean): Promise => { + let currentToken = token + if (!currentToken) { + currentToken = (await readToken(store)) ?? null + if (currentToken) { + setToken(currentToken) + } + } + if (!currentToken && allowHeal) { + const healed = await attemptSelfHeal("remote-access") + if (healed) { + currentToken = (await readToken(store)) ?? null + if (currentToken) { + setToken(currentToken) + } + } + } + return currentToken + } + + const sendRequest = async (machineToken: string, retryAllowed: boolean): Promise => { const response = await fetch(`${apiBaseUrl}/api/machines/remote-access`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + "Idempotency-Key": `${config?.machineId ?? "unknown"}:RustDesk:${info.id}`, + }, body: JSON.stringify({ machineToken, ...payload }), }) + if (!response.ok) { logDesktop("remoteAccess:sync:error", { status: response.status }) const text = await response.text() - if (allowRetry && isTokenRevokedMessage(text)) { + if (retryAllowed && (response.status === 401 || isTokenRevokedMessage(text))) { const healed = await attemptSelfHeal("remote-access") if (healed) { - const refreshedToken = (await readToken(store)) ?? machineToken - return syncRustdeskAccess(refreshedToken, info, false) + const refreshedToken = await resolveToken(false) + if (refreshedToken) { + return sendRequest(refreshedToken, false) + } } } throw new Error(text.slice(0, 300) || "Falha ao registrar acesso remoto") } + const nextInfo: RustdeskInfo = { ...info, lastSyncedAt: Date.now(), lastError: null } - logDesktop("remoteAccess:sync:success", { id: info.id }) await writeRustdeskInfo(store, nextInfo) setRustdeskInfo(nextInfo) + logDesktop("remoteAccess:sync:success", { id: info.id }) + } + + try { + const machineToken = await resolveToken(true) + if (!machineToken) { + const failedInfo: RustdeskInfo = { + ...info, + lastError: "Token indisponível para sincronizar acesso remoto", + } + await writeRustdeskInfo(store, failedInfo) + setRustdeskInfo(failedInfo) + logDesktop("remoteAccess:sync:skipped", { reason: "missing-token" }) + return + } + await sendRequest(machineToken, allowRetry) } catch (error) { const message = error instanceof Error ? error.message : String(error) - if (allowRetry && isTokenRevokedMessage(message)) { - const healed = await attemptSelfHeal("remote-access") - if (healed) { - const refreshedToken = (await readToken(store)) ?? machineToken - return syncRustdeskAccess(refreshedToken, info, false) - } - } console.error("Falha ao sincronizar acesso remoto com a plataforma", error) const failedInfo: RustdeskInfo = { ...info, lastError: message } await writeRustdeskInfo(store, failedInfo) setRustdeskInfo(failedInfo) + if (allowRetry && isTokenRevokedMessage(message)) { + const healed = await attemptSelfHeal("remote-access") + if (healed) { + const refreshedToken = await resolveToken(false) + if (refreshedToken) { + return syncRemoteAccessNow(failedInfo, false) + } + } + } logDesktop("remoteAccess:sync:failed", { id: info.id, error: message }) } }, - [store, attemptSelfHeal] + [store, token, config?.machineId, attemptSelfHeal, setToken] ) -const provisionRustdesk = useCallback( - async (machineId: string, machineToken: string): Promise => { - if (!store || !machineId) return null + const handleRustdeskProvision = useCallback( + async (payload: RustdeskProvisioningResult) => { + if (!store) return + const normalized: RustdeskInfo = { + ...payload, + installedVersion: payload.installedVersion ?? null, + lastSyncedAt: rustdeskInfoRef.current?.lastSyncedAt ?? null, + lastError: null, + } + await writeRustdeskInfo(store, normalized) + setRustdeskInfo(normalized) + await syncRemoteAccessNow(normalized) + }, + [store, syncRemoteAccessNow] + ) + + const ensureRustdesk = useCallback(async () => { + if (!store) return null setIsRustdeskProvisioning(true) try { - const result = await invoke("provision_rustdesk", { machineId }) - const info: RustdeskInfo = { - ...result, - lastProvisionedAt: Date.now(), - lastSyncedAt: null, - } - await writeRustdeskInfo(store, info) - setRustdeskInfo(info) - if (machineToken) { - await syncRustdeskAccess(machineToken, info) - } - return info + const payload = await invoke("ensure_rustdesk_and_emit", { + configString: RUSTDESK_CONFIG_STRING || null, + password: RUSTDESK_DEFAULT_PASSWORD || null, + machineId: config?.machineId ?? null, + }) + await handleRustdeskProvision(payload) + return payload } catch (error) { const message = error instanceof Error ? error.message : String(error) if (message.toLowerCase().includes("apenas no windows")) { @@ -810,15 +863,40 @@ const provisionRustdesk = useCallback( } finally { setIsRustdeskProvisioning(false) } - }, - [store, syncRustdeskAccess] -) + }, [store, config?.machineId, handleRustdeskProvision]) + + useEffect(() => { + if (!store) return + let disposed = false + let unsubscribe: (() => void) | null = null + + listen("raven://remote-access/provisioned", async (event) => { + try { + await handleRustdeskProvision(event.payload) + } catch (error) { + console.error("Falha ao processar evento de provisioning do RustDesk", error) + } + }) + .then((unlisten) => { + if (disposed) { + unlisten() + } else { + unsubscribe = unlisten + } + }) + .catch((error) => console.error("Falha ao registrar listener do RustDesk", error)) + + return () => { + disposed = true + if (unsubscribe) unsubscribe() + } + }, [store, handleRustdeskProvision]) useEffect(() => { - if (!store || !config?.machineId || !token) return + if (!store) return if (!rustdeskInfo && !isRustdeskProvisioning && !rustdeskBootstrapRef.current) { rustdeskBootstrapRef.current = true - provisionRustdesk(config.machineId, token).finally(() => { + ensureRustdesk().finally(() => { rustdeskBootstrapRef.current = false }) return @@ -827,10 +905,10 @@ useEffect(() => { const lastSync = rustdeskInfo.lastSyncedAt ?? 0 const needsSync = Date.now() - lastSync > RUSTDESK_SYNC_INTERVAL_MS if (needsSync) { - syncRustdeskAccess(token, rustdeskInfo) + syncRemoteAccessNow(rustdeskInfo) } } -}, [store, config?.machineId, token, rustdeskInfo, provisionRustdesk, syncRustdeskAccess, isRustdeskProvisioning]) +}, [store, rustdeskInfo, ensureRustdesk, syncRemoteAccessNow, isRustdeskProvisioning]) async function register() { if (!profile) return @@ -907,7 +985,7 @@ useEffect(() => { }, }) - await provisionRustdesk(data.machineId, data.machineToken) + await ensureRustdesk() logDesktop("register:rustdesk:done", { machineId: data.machineId }) // Abre o sistema imediatamente após registrar (evita ficar com token inválido no fluxo antigo)