import os import sys import time import socket import platform import threading import logging from pathlib import Path from typing import Optional, List import httpx logger = logging.getLogger("OnEverAgentGUI") from PyQt6.QtCore import Qt, QThread, pyqtSignal, QSize, QTimer from PyQt6.QtGui import QIcon, QFont, QColor, QAction from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QTabWidget, QLabel, QPushButton, QLineEdit, QTableWidget, QTableWidgetItem, QHeaderView, QProgressBar, QFileDialog, QMessageBox, QSystemTrayIcon, QMenu, QDialog, QFormLayout, QComboBox, QSpinBox, QFrame, QCheckBox ) # Add agent root to sys.path sys.path.insert(0, str(Path(__file__).resolve().parent)) from agent.config import load_config, save_config, AgentConfig, LocalFolderJob from agent.service import AgentDaemon from agent.uploader import ChunkUploader from agent.chunker import compute_file_sha256 from agent.state_db import state_db from create_icons import generate_app_icons # --- Modern Dark QSS Stylesheet --- DARK_QSS = """ QMainWindow, QWidget { background-color: #0B0F19; color: #F8FAFC; font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; } QTabWidget::pane { border: 1px solid rgba(255, 255, 255, 0.08); background-color: #111827; border-radius: 8px; } QTabBar::tab { background: #1E293B; color: #94A3B8; padding: 10px 20px; margin-right: 4px; border-top-left-radius: 6px; border-top-right-radius: 6px; font-weight: bold; } QTabBar::tab:selected { background: #06B6D4; color: #FFFFFF; } QTabBar::tab:hover:!selected { background: #334155; color: #FFFFFF; } QFrame.card { background-color: #1E293B; border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 10px; padding: 16px; } QLineEdit, QComboBox, QSpinBox { background-color: #0B0F19; border: 1px solid #334155; border-radius: 6px; color: #FFFFFF; padding: 8px 12px; font-size: 13px; } QLineEdit:focus, QComboBox:focus, QSpinBox:focus { border: 1px solid #06B6D4; } QCheckBox { color: #E2E8F0; font-size: 13px; spacing: 8px; } QCheckBox::indicator { width: 18px; height: 18px; border-radius: 4px; border: 1px solid #475569; background-color: #0B0F19; } QCheckBox::indicator:checked { background-color: #06B6D4; border-color: #06B6D4; } QPushButton { background-color: #334155; color: #FFFFFF; border: none; border-radius: 6px; padding: 9px 18px; font-weight: bold; } QPushButton:hover { background-color: #475569; } QPushButton.primary { background-color: #06B6D4; color: #FFFFFF; } QPushButton.primary:hover { background-color: #0891B2; } QPushButton.success { background-color: #10B981; color: #FFFFFF; } QPushButton.success:hover { background-color: #059669; } QPushButton.danger { background-color: rgba(244, 63, 94, 0.2); color: #FDA4AF; border: 1px solid rgba(244, 63, 94, 0.4); } QPushButton.danger:hover { background-color: rgba(244, 63, 94, 0.35); } QProgressBar { background-color: #1E293B; border: 1px solid #334155; border-radius: 6px; text-align: center; color: #FFFFFF; font-weight: bold; height: 18px; } QProgressBar::chunk { background-color: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #06B6D4, stop:1 #6366F1); border-radius: 5px; } QTableWidget { background-color: #0B0F19; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 6px; gridline-color: rgba(255, 255, 255, 0.04); } QTableWidget::item { padding: 8px; color: #F8FAFC; } QTableWidget::item:selected { background-color: rgba(6, 182, 212, 0.2); } QHeaderView::section { background-color: #1E293B; color: #94A3B8; padding: 8px; font-weight: bold; border: none; border-bottom: 1px solid #334155; } QMenu { background-color: #1E293B; color: #FFFFFF; border: 1px solid #334155; } QMenu::item:selected { background-color: #06B6D4; } """ class WorkerSignals(QThread): started_signal = pyqtSignal(str, int) progress_signal = pyqtSignal(str, int, int, float) completed_signal = pyqtSignal(str, str, int) error_signal = pyqtSignal(str, str) status_signal = pyqtSignal(str, str) class AddFolderDialog(QDialog): """Dialog for selecting and configuring a Windows backup folder.""" def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("Añadir Carpeta de Backup — OnEver Drive") self.resize(500, 320) self.setStyleSheet(DARK_QSS) layout = QVBoxLayout(self) layout.setSpacing(14) form = QFormLayout() form.setSpacing(12) self.txt_name = QLineEdit() self.txt_name.setPlaceholderText("Ej: Base de Datos SQL Producción") form.addRow("Nombre descriptivo:", self.txt_name) path_layout = QHBoxLayout() self.txt_path = QLineEdit() self.txt_path.setPlaceholderText("C:\\SQLBackups") btn_browse = QPushButton("Explorar...") btn_browse.clicked.connect(self._browse_folder) path_layout.addWidget(self.txt_path) path_layout.addWidget(btn_browse) form.addRow("Ruta en Windows:", path_layout) self.txt_patterns = QLineEdit() self.txt_patterns.setText("*.bak,*.mdf") form.addRow("Filtros de archivo:", self.txt_patterns) self.cmb_schedule = QComboBox() self.cmb_schedule.addItems(["hourly", "daily", "weekly", "monthly"]) self.cmb_schedule.setCurrentText("daily") form.addRow("Planificación (Schedule):", self.cmb_schedule) self.spin_stable = QSpinBox() self.spin_stable.setRange(10, 600) self.spin_stable.setValue(60) self.spin_stable.setSuffix(" seg") form.addRow("Estabilidad de archivo (Locks):", self.spin_stable) layout.addLayout(form) btn_layout = QHBoxLayout() btn_layout.addStretch() btn_cancel = QPushButton("Cancelar") btn_cancel.clicked.connect(self.reject) btn_save = QPushButton("Guardar Carpeta") btn_save.setProperty("class", "primary") btn_save.clicked.connect(self._validate_and_accept) btn_layout.addWidget(btn_cancel) btn_layout.addWidget(btn_save) layout.addLayout(btn_layout) def _browse_folder(self): folder = QFileDialog.getExistingDirectory(self, "Seleccionar carpeta para backup") if folder: self.txt_path.setText(folder) if not self.txt_name.text(): self.txt_name.setText(Path(folder).name) def _validate_and_accept(self): if not self.txt_path.text() or not os.path.exists(self.txt_path.text()): QMessageBox.warning(self, "Ruta Inválida", "Por favor selecciona una carpeta existente en Windows.") return if not self.txt_name.text(): self.txt_name.setText(Path(self.txt_path.text()).name) self.accept() def get_data(self) -> LocalFolderJob: return LocalFolderJob( name=self.txt_name.text().strip(), source_path=self.txt_path.text().strip(), file_patterns=self.txt_patterns.text().strip() or "*.*", schedule_cron=self.cmb_schedule.currentText(), min_stable_seconds=self.spin_stable.value() ) class OnEverDriveMainWindow(QMainWindow): """Main modern desktop GUI and tray controller for OnEver Drive Windows Agent.""" def __init__(self): super().__init__() self.setWindowTitle("OnEver Drive — Agente de Backup Windows") self.resize(780, 580) self.setStyleSheet(DARK_QSS) self.config = load_config() self.app_icon = self._load_app_icon() self.setWindowIcon(self.app_icon) # Background Worker Daemon self.signals = WorkerSignals() self.daemon = AgentDaemon( config=self.config, on_started=lambda f, b: self.signals.started_signal.emit(f, b), on_progress=lambda f, d, t, p: self.signals.progress_signal.emit(f, d, t, p), on_completed=lambda f, s, b: self.signals.completed_signal.emit(f, s, b), on_error=lambda f, e: self.signals.error_signal.emit(f, e), on_status=lambda s, m: self.signals.status_signal.emit(s, m) ) self._connect_signals() self._init_ui() self._init_tray() # Timer to refresh folders list in GUI with server-assigned jobs periodically self.timer_refresh = QTimer(self) self.timer_refresh.setInterval(10000) # every 10 seconds self.timer_refresh.timeout.connect(self._refresh_folders_table) self.timer_refresh.start() if self.config.device_id: self.daemon.start() def _load_app_icon(self) -> QIcon: assets_dir = Path(__file__).resolve().parent / "assets" png_path = assets_dir / "icon.png" if not png_path.exists(): _, png_path = generate_app_icons() return QIcon(str(png_path)) def _connect_signals(self): self.signals.started_signal.connect(self._on_backup_started) self.signals.progress_signal.connect(self._on_live_progress) self.signals.completed_signal.connect(self._on_backup_completed) self.signals.error_signal.connect(self._on_backup_error) self.signals.status_signal.connect(self._on_daemon_status) def _init_ui(self): central = QWidget() self.setCentralWidget(central) main_layout = QVBoxLayout(central) main_layout.setContentsMargins(20, 20, 20, 20) main_layout.setSpacing(16) # Header Bar header = QHBoxLayout() title_box = QVBoxLayout() lbl_title = QLabel("OnEver Drive") lbl_title.setFont(QFont("Segoe UI", 16, QFont.Weight.Bold)) lbl_sub = QLabel("Agente Empresarial de Sincronización y Backup para Windows") lbl_sub.setStyleSheet("color: #94A3B8; font-size: 11px;") title_box.addWidget(lbl_title) title_box.addWidget(lbl_sub) header.addLayout(title_box) header.addStretch() self.lbl_status_badge = QLabel("● Desconectado") self.lbl_status_badge.setStyleSheet("background-color: #7F1D1D; color: #FCA5A5; font-weight: bold; border-radius: 4px; padding: 6px 12px;") header.addWidget(self.lbl_status_badge) main_layout.addLayout(header) # Tabs self.tabs = QTabWidget() self.tab_dashboard = QWidget() self.tab_folders = QWidget() self.tab_config = QWidget() self.tab_history = QWidget() self.tabs.addTab(self.tab_dashboard, "Dashboard") self.tabs.addTab(self.tab_folders, "Carpetas de Backup") self.tabs.addTab(self.tab_config, "Servidor & Config") self.tabs.addTab(self.tab_history, "Historial de Archivos") main_layout.addWidget(self.tabs) self._build_tab_dashboard() self._build_tab_folders() self._build_tab_config() self._build_tab_history() self._update_header_status() # --- TAB 1: DASHBOARD --- def _build_tab_dashboard(self): layout = QVBoxLayout(self.tab_dashboard) layout.setContentsMargins(16, 16, 16, 16) layout.setSpacing(16) card_tel = QFrame() card_tel.setProperty("class", "card") tel_layout = QVBoxLayout(card_tel) lbl_sec = QLabel("Transferencia en Vivo (Motor de Chunks)") lbl_sec.setFont(QFont("Segoe UI", 12, QFont.Weight.Bold)) tel_layout.addWidget(lbl_sec) self.lbl_transfer_info = QLabel("Estado: En espera de cambios en carpetas monitoreadas...") self.lbl_transfer_info.setStyleSheet("color: #94A3B8;") tel_layout.addWidget(self.lbl_transfer_info) self.pbar_transfer = QProgressBar() self.pbar_transfer.setValue(0) tel_layout.addWidget(self.pbar_transfer) self.lbl_chunk_details = QLabel("Chunks: 0 / 0 | Motor por Bloques de 4MB | SHA-256: —") self.lbl_chunk_details.setStyleSheet("color: #64748B; font-family: 'Consolas', monospace; font-size: 11px;") tel_layout.addWidget(self.lbl_chunk_details) layout.addWidget(card_tel) card_info = QFrame() card_info.setProperty("class", "card") info_layout = QVBoxLayout(card_info) lbl_info_title = QLabel("Información del Dispositivo") lbl_info_title.setFont(QFont("Segoe UI", 11, QFont.Weight.Bold)) info_layout.addWidget(lbl_info_title) self.lbl_dash_client = QLabel("Cliente ID: —") self.lbl_dash_server = QLabel("Servidor Proxmox: —") self.lbl_dash_folders = QLabel("Carpetas en Monitoreo: 0") info_layout.addWidget(self.lbl_dash_client) info_layout.addWidget(self.lbl_dash_server) info_layout.addWidget(self.lbl_dash_folders) layout.addWidget(card_info) btn_box = QHBoxLayout() btn_backup_all = QPushButton("▶ Iniciar Sincronización Manual Ahora") btn_backup_all.setProperty("class", "primary") btn_backup_all.clicked.connect(self._trigger_all_backups) btn_box.addWidget(btn_backup_all) layout.addLayout(btn_box) layout.addStretch() # --- TAB 2: CARPETAS DE BACKUP --- def _build_tab_folders(self): layout = QVBoxLayout(self.tab_folders) layout.setContentsMargins(16, 16, 16, 16) layout.setSpacing(12) top_bar = QHBoxLayout() lbl = QLabel("Carpetas de Windows Monitoreadas") lbl.setFont(QFont("Segoe UI", 12, QFont.Weight.Bold)) top_bar.addWidget(lbl) top_bar.addStretch() btn_add = QPushButton("➕ Añadir Carpeta...") btn_add.setProperty("class", "primary") btn_add.clicked.connect(self._show_add_folder_dialog) top_bar.addWidget(btn_add) layout.addLayout(top_bar) self.tbl_folders = QTableWidget(0, 5) self.tbl_folders.setHorizontalHeaderLabels(["Nombre", "Ruta en Windows", "Filtros", "Intervalo", "Último Estado"]) self.tbl_folders.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) self.tbl_folders.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) layout.addWidget(self.tbl_folders) btn_row = QHBoxLayout() btn_delete = QPushButton("🗑️ Eliminar Carpeta") btn_delete.setProperty("class", "danger") btn_delete.clicked.connect(self._delete_selected_folder) btn_row.addWidget(btn_delete) btn_row.addStretch() layout.addLayout(btn_row) self._refresh_folders_table() # --- TAB 3: CONFIGURACIÓN & NOTIFICACIONES --- def _build_tab_config(self): layout = QVBoxLayout(self.tab_config) layout.setContentsMargins(16, 16, 16, 16) layout.setSpacing(16) # Server Card card_srv = QFrame() card_srv.setProperty("class", "card") form = QFormLayout(card_srv) form.setSpacing(12) lbl = QLabel("Conexión con el Servidor Central Proxmox VE") lbl.setFont(QFont("Segoe UI", 12, QFont.Weight.Bold)) form.addRow(lbl) self.txt_server_url = QLineEdit() self.txt_server_url.setText(self.config.server_url) btn_ping = QPushButton("🔍 Probar Conexión") btn_ping.clicked.connect(self._test_server_connection) srv_box = QHBoxLayout() srv_box.addWidget(self.txt_server_url) srv_box.addWidget(btn_ping) form.addRow("URL Servidor:", srv_box) self.txt_reg_code = QLineEdit() self.txt_reg_code.setPlaceholderText("Código generado en la Web (ej: OED-XXXX-XXXX)") form.addRow("Código de Registro:", self.txt_reg_code) btn_register = QPushButton("🚀 Registrar / Re-vincular Dispositivo") btn_register.setProperty("class", "primary") btn_register.clicked.connect(self._register_device_api) form.addRow("", btn_register) layout.addWidget(card_srv) # Notifications Preferences Card (Clean & Non-invasive) card_notif = QFrame() card_notif.setProperty("class", "card") notif_layout = QVBoxLayout(card_notif) notif_layout.setSpacing(10) lbl_notif = QLabel("Preferencias de Notificaciones en Windows") lbl_notif.setFont(QFont("Segoe UI", 12, QFont.Weight.Bold)) notif_layout.addWidget(lbl_notif) self.chk_notif_main = QCheckBox("Habilitar notificaciones en el Área de Notificaciones (System Tray)") self.chk_notif_main.setChecked(self.config.enable_notifications) self.chk_notif_main.stateChanged.connect(self._save_notif_settings) notif_layout.addWidget(self.chk_notif_main) self.chk_notif_start = QCheckBox("Notificar únicamente cuando INICIA un proceso de respaldo") self.chk_notif_start.setChecked(self.config.notify_on_start) self.chk_notif_start.stateChanged.connect(self._save_notif_settings) notif_layout.addWidget(self.chk_notif_start) self.chk_notif_complete = QCheckBox("Notificar únicamente cuando FINALIZA con éxito (Confirmación SHA-256)") self.chk_notif_complete.setChecked(self.config.notify_on_complete) self.chk_notif_complete.stateChanged.connect(self._save_notif_settings) notif_layout.addWidget(self.chk_notif_complete) self.chk_notif_error = QCheckBox("Notificar en caso de error o pérdida de conexión") self.chk_notif_error.setChecked(self.config.notify_on_error) self.chk_notif_error.stateChanged.connect(self._save_notif_settings) notif_layout.addWidget(self.chk_notif_error) layout.addWidget(card_notif) layout.addStretch() # --- TAB 4: HISTORIAL --- def _build_tab_history(self): layout = QVBoxLayout(self.tab_history) layout.setContentsMargins(16, 16, 16, 16) layout.setSpacing(12) lbl = QLabel("Historial de Archivos Respaldados Localmente") lbl.setFont(QFont("Segoe UI", 12, QFont.Weight.Bold)) layout.addWidget(lbl) self.tbl_history = QTableWidget(0, 4) self.tbl_history.setHorizontalHeaderLabels(["Archivo", "Tamaño", "Integridad SHA-256", "Fecha de Respaldo"]) self.tbl_history.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) layout.addWidget(self.tbl_history) self._refresh_history_table() # --- TRAY ICON & WINDOW CLOSE BEHAVIOR --- def _init_tray(self): self.tray = QSystemTrayIcon(self) self.tray.setIcon(self.app_icon) self.tray.setToolTip(f"OnEver Drive — {self.config.client_code or 'Sin Registrar'}") self._build_tray_menu() self.tray.activated.connect(self._on_tray_activated) self.tray.show() def _build_tray_menu(self): menu = QMenu() client_label = self.config.client_code or "Sin Registrar" act_title = QAction(f"OnEver Drive ({client_label})", self) act_title.setEnabled(False) menu.addAction(act_title) menu.addSeparator() act_open = QAction("🖥️ Abrir Panel de Control", self) act_open.triggered.connect(self.showNormal) menu.addAction(act_open) act_sync = QAction("▶ Respaldar Todo Ahora", self) act_sync.triggered.connect(self._trigger_all_backups) menu.addAction(act_sync) menu.addSeparator() # Notification direct toggle in tray self.act_tray_notif = QAction("🔔 Notificaciones Activadas" if self.config.enable_notifications else "🔕 Notificaciones Silenciadas", self) self.act_tray_notif.triggered.connect(self._toggle_tray_notifications) menu.addAction(self.act_tray_notif) menu.addSeparator() act_exit = QAction("❌ Salir", self) act_exit.triggered.connect(self._clean_exit) menu.addAction(act_exit) self.tray.setContextMenu(menu) def _toggle_tray_notifications(self): self.config = load_config() self.config.enable_notifications = not self.config.enable_notifications save_config(self.config) self.chk_notif_main.setChecked(self.config.enable_notifications) self._build_tray_menu() def _save_notif_settings(self): self.config = load_config() self.config.enable_notifications = self.chk_notif_main.isChecked() self.config.notify_on_start = self.chk_notif_start.isChecked() self.config.notify_on_complete = self.chk_notif_complete.isChecked() self.config.notify_on_error = self.chk_notif_error.isChecked() save_config(self.config) self._build_tray_menu() def _on_tray_activated(self, reason): # Safely convert to int to bypass PyQt6 enum comparison bugs try: val = int(reason) except Exception: val = reason.value if hasattr(reason, 'value') else reason if val in (2, 3): # 2: DoubleClick, 3: Trigger self.showNormal() self.activateWindow() def closeEvent(self, event): """Minimize silently to system tray on close without showing intrusive popups.""" event.ignore() self.hide() def _clean_exit(self): self.daemon.stop() QApplication.quit() # --- ACTIONS & NOTIFICATION TRIGGERS (Discrete: Start & Finish only) --- def _update_header_status(self): self.config = load_config() if self.config.client_code: self.lbl_status_badge.setText(f"● Conectado ({self.config.client_code})") self.lbl_status_badge.setStyleSheet("background-color: #065F46; color: #34D399; font-weight: bold; border-radius: 4px; padding: 6px 12px;") self.lbl_dash_client.setText(f"Cliente ID: {self.config.client_code} ({self.config.client_name or 'Local'})") self.lbl_dash_server.setText(f"Servidor Proxmox: {self.config.server_url}") self.lbl_dash_folders.setText(f"Carpetas en Monitoreo: {len(self.config.local_folders)}") else: self.lbl_status_badge.setText("● Sin Registrar") self.lbl_status_badge.setStyleSheet("background-color: #7F1D1D; color: #FCA5A5; font-weight: bold; border-radius: 4px; padding: 6px 12px;") def _show_add_folder_dialog(self): dialog = AddFolderDialog(self) if dialog.exec() == QDialog.DialogCode.Accepted: new_job = dialog.get_data() self.config = load_config() self.config.local_folders.append(new_job) save_config(self.config) self._refresh_folders_table() self._update_header_status() QMessageBox.information(self, "Carpeta Añadida", f"La carpeta '{new_job.name}' ha sido configurada y está siendo monitoreada.") def _refresh_folders_table(self): self.config = load_config() server_jobs = getattr(self.daemon, "last_server_jobs", []) # Combine local folders and server-assigned jobs total_rows = len(server_jobs) + len(self.config.local_folders) self.tbl_folders.setRowCount(total_rows) # Show server jobs first row = 0 for job in server_jobs: self.tbl_folders.setItem(row, 0, QTableWidgetItem(job.get("name", ""))) self.tbl_folders.setItem(row, 1, QTableWidgetItem(job.get("source_path", ""))) self.tbl_folders.setItem(row, 2, QTableWidgetItem(job.get("file_patterns", ""))) self.tbl_folders.setItem(row, 3, QTableWidgetItem(job.get("schedule_cron", ""))) status_item = QTableWidgetItem("Sincronizado (Web)") status_item.setForeground(QColor("#34D399")) # Light green color in dark mode self.tbl_folders.setItem(row, 4, status_item) row += 1 # Then show local folders for job in self.config.local_folders: self.tbl_folders.setItem(row, 0, QTableWidgetItem(job.name)) self.tbl_folders.setItem(row, 1, QTableWidgetItem(job.source_path)) self.tbl_folders.setItem(row, 2, QTableWidgetItem(job.file_patterns)) self.tbl_folders.setItem(row, 3, QTableWidgetItem(getattr(job, "schedule_cron", "daily"))) self.tbl_folders.setItem(row, 4, QTableWidgetItem(job.last_status or "En espera (Local)")) row += 1 def _delete_selected_folder(self): row = self.tbl_folders.currentRow() if row < 0: QMessageBox.warning(self, "Selección", "Por favor selecciona una carpeta para eliminar.") return job_name = self.tbl_folders.item(row, 0).text() reply = QMessageBox.question(self, "Confirmar", f"¿Eliminar el trabajo de backup '{job_name}' del Agente y del Servidor?") if reply == QMessageBox.StandardButton.Yes: self.config = load_config() path_val = self.tbl_folders.item(row, 1).text() matched_jobs = [f for f in self.config.local_folders if f.source_path == path_val] if matched_jobs: job = matched_jobs[0] if job.job_id: try: base_url = self.config.server_url.rstrip("/") headers = { "X-Device-Id": self.config.device_id, "X-Device-Token": self.config.device_token } resp = httpx.delete(f"{base_url}/api/jobs/agent/{job.job_id}", headers=headers, timeout=5.0) if resp.status_code != 200: QMessageBox.warning(self, "Advertencia", f"No se pudo eliminar en el servidor: {resp.text}.\nSe eliminará localmente.") except Exception as e: logger.warning(f"Failed to delete job on server: {e}") QMessageBox.warning(self, "Advertencia", f"No se pudo contactar al servidor: {e}.\nSe eliminará localmente.") self.config.local_folders = [f for f in self.config.local_folders if f.source_path != path_val] save_config(self.config) self._refresh_folders_table() self._update_header_status() def _test_server_connection(self): url = self.txt_server_url.text().strip().rstrip("/") if not url: QMessageBox.warning(self, "Error", "Ingresa una URL de servidor.") return try: t0 = time.time() with httpx.Client(timeout=5.0) as client: r = client.get(f"{url}/health") elapsed_ms = int((time.time() - t0) * 1000) if r.status_code == 200: QMessageBox.information(self, "Conexión Exitosa", f"✓ Servidor OnEver Drive alcanzable.\nLatencia: {elapsed_ms} ms\nRespuesta: {r.json()}") else: QMessageBox.warning(self, "Error", f"El servidor respondió con código {r.status_code}") except Exception as ex: QMessageBox.critical(self, "Error de Conexión", f"No se pudo contactar al servidor en {url}:\n{str(ex)}") def _register_device_api(self): server_url = self.txt_server_url.text().strip().rstrip("/") code = self.txt_reg_code.text().strip().upper() if not server_url or not code: QMessageBox.warning(self, "Error", "Debes ingresar la URL del servidor y el código de registro.") return try: hostname = socket.gethostname() os_info = f"{platform.system()} {platform.release()} (Build {platform.version()})" payload = { "registration_code": code, "name": hostname, "hostname": hostname, "os_info": os_info, "agent_version": "1.0.0" } with httpx.Client(timeout=15.0) as client: resp = client.post(f"{server_url}/api/clients/register", json=payload) if resp.status_code != 200: QMessageBox.warning(self, "Registro Fallido", f"El servidor denegó el registro: {resp.text}") return data = resp.json() self.config.server_url = server_url self.config.client_code = data["client_code"] self.config.device_id = data["device_id"] self.config.device_token = data["device_token"] self.config.client_name = data["name"] save_config(self.config) self._update_header_status() self._build_tray_menu() self.daemon.start() QMessageBox.information(self, "Registro Exitoso", f"¡Dispositivo vinculado con éxito!\nCliente: {data['client_code']}") except Exception as ex: QMessageBox.critical(self, "Error", f"Error durante el registro: {str(ex)}") def _trigger_all_backups(self): if not self.config.device_id: QMessageBox.warning(self, "Sin Registro", "Primero vincula el dispositivo en la pestaña Servidor & Config.") return threading.Thread(target=lambda: self.daemon._run_backup_cycle(force=True), daemon=True).start() self.lbl_transfer_info.setText("Iniciando escaneo de carpetas y comprobación de locks...") # --- DISCRETE NOTIFICATION HANDLERS (START & FINISH ONLY) --- def _on_backup_started(self, filename: str, file_size: int): mb = file_size / (1024 * 1024) self.lbl_transfer_info.setText(f"Iniciando respaldo de: {filename} ({mb:.2f} MB)") # Single notification at START of backup (if enabled) if self.config.enable_notifications and self.config.notify_on_start: self.tray.showMessage( "OnEver Drive — Inicio de Respaldo", f"Iniciando transferencia de {filename} ({mb:.2f} MB)...", QSystemTrayIcon.MessageIcon.Information, 2500 ) def _on_live_progress(self, filename: str, done: int, total: int, pct: float): # Progress updates ONLY update the GUI progressbar silently (no popups) self.pbar_transfer.setValue(int(pct)) self.lbl_transfer_info.setText(f"Subiendo {filename}...") self.lbl_chunk_details.setText(f"Chunks: {done} / {total} ({pct:.1f}%) | Motor por Bloques de 4MB Activo") def _on_backup_completed(self, filename: str, sha256: str, file_size: int): self.pbar_transfer.setValue(100) self.lbl_transfer_info.setText(f"✓ Backup verificado e íntegro: {filename}") self.lbl_chunk_details.setText(f"SHA-256: {sha256[:16]}... | Tamaño: {file_size / (1024*1024):.2f} MB") # Single notification at FINISH of backup (if enabled) if self.config.enable_notifications and self.config.notify_on_complete: self.tray.showMessage( "OnEver Drive — Respaldo Exitoso ✓", f"{filename} respaldado y verificado en el servidor central (SHA-256).", QSystemTrayIcon.MessageIcon.Information, 3000 ) self._refresh_history_table() self._refresh_folders_table() def _on_backup_error(self, filename: str, err: str): self.lbl_transfer_info.setText(f"✗ Error al respaldar {filename}") self.lbl_chunk_details.setText(f"Detalle: {err}") # Notification on ERROR (if enabled) if self.config.enable_notifications and self.config.notify_on_error: self.tray.showMessage( "OnEver Drive — Error en Respaldo ✗", f"Fallo al respaldar {filename}: {err}", QSystemTrayIcon.MessageIcon.Warning, 4000 ) def _on_daemon_status(self, status: str, message: str): if status == "ONLINE": self._update_header_status() def _refresh_history_table(self): try: with state_db._get_conn() as conn: cursor = conn.cursor() cursor.execute("SELECT filepath, file_size, sha256, last_backup_time FROM completed_files ORDER BY last_backup_time DESC LIMIT 50") rows = cursor.fetchall() self.tbl_history.setRowCount(len(rows)) for r_idx, row in enumerate(rows): self.tbl_history.setItem(r_idx, 0, QTableWidgetItem(Path(row[0]).name)) self.tbl_history.setItem(r_idx, 1, QTableWidgetItem(f"{row[1] / (1024*1024):.2f} MB")) self.tbl_history.setItem(r_idx, 2, QTableWidgetItem(row[2][:16] + "...")) self.tbl_history.setItem(r_idx, 3, QTableWidgetItem(str(row[3]))) except Exception: pass def main(): app = QApplication(sys.argv) app.setQuitOnLastWindowClosed(False) window = OnEverDriveMainWindow() window.show() sys.exit(app.exec()) if __name__ == "__main__": main()