diff --git a/logica/correo.py b/logica/correo.py index 4381fa1..7de7158 100644 --- a/logica/correo.py +++ b/logica/correo.py @@ -99,6 +99,7 @@ class CorreoClient: def obtener_bandeja(self): """Lista correos del INBOX. Devuelve lista de dicts con uid, de, asunto, fecha.""" + self.imap.noop() # Fuerza sincronización con el servidor antes de leer self.imap.select("INBOX") status, data = self.imap.uid("search", None, "ALL") if status != "OK": @@ -108,10 +109,13 @@ class CorreoClient: correos = [] for uid in reversed(uids[-100:]): # Últimos 100, más recientes primero - status, msg_data = self.imap.uid("fetch", uid, "(BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)])") + status, msg_data = self.imap.uid("fetch", uid, "(FLAGS BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)])") if status != "OK" or not msg_data[0]: continue + info_raw = msg_data[0][0].decode("utf-8", errors="replace") + leido = "\\Seen" in info_raw + raw = msg_data[0][1] msg = email.message_from_bytes(raw) @@ -124,10 +128,19 @@ class CorreoClient: "de": de, "asunto": asunto, "fecha": fecha, + "leido": leido, }) return correos + def marcar_leido(self, uid): + """Marca un correo como leído en el servidor.""" + try: + self.imap.select("INBOX") + self.imap.uid("store", uid.encode(), "+FLAGS", "\\Seen") + except Exception: + pass + def leer_correo(self, uid): """Lee el contenido completo de un correo por UID. Devuelve texto plano.""" self.imap.select("INBOX") diff --git a/logica/red/cliente.py b/logica/red/cliente.py index eb1b21a..67f8166 100644 --- a/logica/red/cliente.py +++ b/logica/red/cliente.py @@ -3,18 +3,23 @@ import socket from logica.red.servidor import APP_FIRMA, PUERTO_BROADCAST -def descubrir_servidores(timeout=5): +def descubrir_servidores(timeout_sin_nuevos=10, callback=None, on_seen=None, stop_event=None): """Escucha broadcasts UDP para encontrar servidores disponibles. - Retorna una lista de tuplas (ip, puerto) de servidores encontrados. + - callback(ip, puerto): llamado cuando se descubre un servidor nuevo. + - on_seen(ip, puerto): llamado en cada broadcast valido (nuevos y ya conocidos). + - timeout_sin_nuevos: segundos sin nuevos servidores antes de parar (default 10). + - stop_event: threading.Event para cancelar la busqueda desde fuera. + Retorna una lista de tuplas (ip, puerto). """ - print(f"[DEBUG CLI] Iniciando descubrimiento UDP en puerto {PUERTO_BROADCAST} (timeout={timeout}s)") + import time + print(f"[DEBUG CLI] Iniciando descubrimiento UDP en puerto {PUERTO_BROADCAST} (timeout_sin_nuevos={timeout_sin_nuevos}s)") servidores = [] sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) if hasattr(socket, "SO_REUSEPORT"): sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) - sock.settimeout(timeout) + sock.settimeout(1) try: sock.bind(("", PUERTO_BROADCAST)) print(f"[DEBUG CLI] Socket UDP enlazado a '':{PUERTO_BROADCAST}") @@ -23,23 +28,37 @@ def descubrir_servidores(timeout=5): sock.close() return servidores + ultimo_encontrado = time.time() try: while True: - datos, direccion = sock.recvfrom(1024) - mensaje = datos.decode("utf-8") - print(f"[DEBUG CLI] Paquete UDP recibido de {direccion}: {mensaje!r}") - if verificar_firma(mensaje): - partes = mensaje.split("|") - if len(partes) == 2: - puerto_servidor = int(partes[1]) - entrada = (direccion[0], puerto_servidor) - if entrada not in servidores: - servidores.append(entrada) - print(f"[DEBUG CLI] Servidor descubierto: {entrada[0]}:{entrada[1]}") - else: - print(f"[DEBUG CLI] Firma no valida, ignorado") - except socket.timeout: - print(f"[DEBUG CLI] Timeout alcanzado") + if stop_event and stop_event.is_set(): + print(f"[DEBUG CLI] Busqueda cancelada externamente") + break + if time.time() - ultimo_encontrado >= timeout_sin_nuevos: + print(f"[DEBUG CLI] {timeout_sin_nuevos}s sin nuevos servidores, finalizando") + break + try: + datos, direccion = sock.recvfrom(1024) + mensaje = datos.decode("utf-8") + print(f"[DEBUG CLI] Paquete UDP recibido de {direccion}: {mensaje!r}") + if verificar_firma(mensaje): + partes = mensaje.split("|") + if len(partes) == 2: + puerto_servidor = int(partes[1]) + ip = direccion[0] + if on_seen: + on_seen(ip, puerto_servidor) + entrada = (ip, puerto_servidor) + if entrada not in servidores: + servidores.append(entrada) + ultimo_encontrado = time.time() + print(f"[DEBUG CLI] Servidor descubierto: {ip}:{puerto_servidor}") + if callback: + callback(ip, puerto_servidor) + else: + print(f"[DEBUG CLI] Firma no valida, ignorado") + except socket.timeout: + pass finally: sock.close() diff --git a/logica/red/selector.py b/logica/red/selector.py index b617b6c..22f46bd 100644 --- a/logica/red/selector.py +++ b/logica/red/selector.py @@ -20,6 +20,10 @@ def obtener_ip_local(): return "127.0.0.1" +_clientes = [] # lista de (conn, addr) +_clientes_lock = threading.Lock() + + def modo_servidor(): servidor, puerto, contrasena_alfa, _broadcast_stop = iniciar_servidor() clave_acceso = f"{puerto}#{contrasena_alfa}" @@ -27,8 +31,22 @@ def modo_servidor(): print(f"[SERVIDOR] IP: {ip}") print(f"[SERVIDOR] Escuchando en puerto {puerto}") print(f"[SERVIDOR] Clave de acceso: {clave_acceso}") - print("[SERVIDOR] Esperando clientes...\n") + print("[SERVIDOR] Esperando clientes... (escribe para enviar a todos, Ctrl+C para salir)\n") + hilo_accept = threading.Thread(target=_aceptar_clientes, args=(servidor, clave_acceso), daemon=True) + hilo_accept.start() + + try: + while True: + mensaje = input() + if mensaje: + _broadcast(f"[SERVIDOR]: {mensaje}") + except (KeyboardInterrupt, EOFError): + print("\n[SERVIDOR] Cerrando...") + servidor.close() + + +def _aceptar_clientes(servidor, clave_acceso): try: while True: conn, addr = servidor.accept() @@ -36,18 +54,19 @@ def modo_servidor(): if autenticar_cliente(datos, clave_acceso): conn.sendall("OK".encode("utf-8")) print(f"[SERVIDOR] Cliente {addr[0]}:{addr[1]} autenticado") - hilo = threading.Thread(target=manejar_cliente, args=(conn, addr), daemon=True) + with _clientes_lock: + _clientes.append((conn, addr)) + hilo = threading.Thread(target=_manejar_cliente, args=(conn, addr), daemon=True) hilo.start() else: conn.sendall("DENIED".encode("utf-8")) conn.close() print(f"[SERVIDOR] Cliente {addr[0]}:{addr[1]} rechazado (clave incorrecta)") - except KeyboardInterrupt: - print("\n[SERVIDOR] Cerrando...") - servidor.close() + except OSError: + pass -def manejar_cliente(conn, addr): +def _manejar_cliente(conn, addr): etiqueta = f"{addr[0]}:{addr[1]}" try: while True: @@ -56,14 +75,27 @@ def manejar_cliente(conn, addr): break mensaje = datos.decode("utf-8") print(f"[{etiqueta}] {mensaje}") - conn.sendall(f"Echo: {mensaje}".encode("utf-8")) + _broadcast(f"{etiqueta}: {mensaje}", origen=conn) except (ConnectionResetError, OSError): pass finally: + with _clientes_lock: + _clientes[:] = [(c, a) for c, a in _clientes if c is not conn] print(f"[SERVIDOR] Cliente {etiqueta} desconectado") conn.close() +def _broadcast(mensaje, origen=None): + """Envía un mensaje a todos los clientes excepto al origen.""" + with _clientes_lock: + destinatarios = [(c, a) for c, a in _clientes if c is not origen] + for conn, addr in destinatarios: + try: + conn.sendall(mensaje.encode("utf-8")) + except OSError: + pass + + def buscar_servidores(): """Busca servidores con reintentos. Retorna lista de servidores o None.""" MAX_INTENTOS = 3 diff --git a/vista/central_panel/view_correos.py b/vista/central_panel/view_correos.py index f3fc12a..3b10c61 100644 --- a/vista/central_panel/view_correos.py +++ b/vista/central_panel/view_correos.py @@ -18,6 +18,8 @@ class CorreosPanel(ttk.Frame): self.root = root self.cliente = CorreoClient() self.correos_cache = [] + self._autorefresh_id = None + self._refreshing = False # Frames apilados (login, bandeja, redactar) self.frame_login = ttk.Frame(self) @@ -207,6 +209,7 @@ class CorreosPanel(ttk.Frame): self.lbl_usuario.config(text=f" {user}") self._poblar_bandeja(correos) self._mostrar_frame(self.frame_bandeja) + self._iniciar_autorefresh() def _conexion_fallida(self, error): self.btn_conectar.config(state="normal") @@ -253,6 +256,9 @@ class CorreosPanel(ttk.Frame): self.tree.pack(side="left", fill="both", expand=True) scrollbar.pack(side="right", fill="y") + self.tree.tag_configure("no_leido", font=(FUENTE_FAMILIA, 9, "bold")) + self.tree.tag_configure("leido", font=(FUENTE_FAMILIA, 9)) + self.tree.bind("", self._on_doble_clic_correo) # Área de lectura de correo @@ -264,20 +270,36 @@ class CorreosPanel(ttk.Frame): self.txt_lectura.pack(fill="both", expand=True, padx=10, pady=(0, 10)) def _poblar_bandeja(self, correos): - """Rellena el Treeview con la lista de correos.""" + """Rellena el Treeview con la lista de correos preservando la selección.""" self.correos_cache = correos + sel_actual = self.tree.selection() + uid_sel = sel_actual[0] if sel_actual else None + self.tree.delete(*self.tree.get_children()) for c in correos: + tag = "leido" if c.get("leido", True) else "no_leido" self.tree.insert("", "end", iid=c["uid"], - values=(c["de"], c["asunto"], c["fecha"])) + values=(c["de"], c["asunto"], c["fecha"]), + tags=(tag,)) + + if uid_sel and self.tree.exists(uid_sel): + self.tree.selection_set(uid_sel) + self.tree.see(uid_sel) def _on_doble_clic_correo(self, event): - """Muestra el contenido del correo seleccionado.""" + """Muestra el contenido del correo seleccionado y lo marca como leído.""" sel = self.tree.selection() if not sel: return uid = sel[0] + # Marcar visualmente como leído de inmediato + self.tree.item(uid, tags=("leido",)) + for c in self.correos_cache: + if c["uid"] == uid: + c["leido"] = True + break + self.txt_lectura.config(state="normal") self.txt_lectura.delete("1.0", "end") self.txt_lectura.insert("1.0", "Cargando...") @@ -286,6 +308,7 @@ class CorreosPanel(ttk.Frame): def tarea(): try: contenido = self.cliente.leer_correo(uid) + self.cliente.marcar_leido(uid) self.root.after(0, lambda: self._mostrar_contenido(contenido)) except Exception as e: self.root.after(0, lambda: self._mostrar_contenido(f"Error: {e}")) @@ -311,6 +334,7 @@ class CorreosPanel(ttk.Frame): def _on_logout(self): """Cierra sesión y vuelve al login.""" + self._detener_autorefresh() self.cliente.desconectar() self.tree.delete(*self.tree.get_children()) self.txt_lectura.config(state="normal") @@ -409,10 +433,62 @@ class CorreosPanel(ttk.Frame): self.btn_enviar.config(state="normal") self.lbl_estado_envio.config(text=f"Error: {error}", foreground="red") + # ================================================================== + # AUTO-REFRESCO + # ================================================================== + + _INTERVALO_REFRESCO_MS = 1000 + + def _iniciar_autorefresh(self): + self._detener_autorefresh() + self._autorefresh_id = self.root.after( + self._INTERVALO_REFRESCO_MS, self._autorefresh_tick + ) + + def _autorefresh_tick(self): + """Lanzado desde el hilo principal. Si ya hay refresco en curso, reintenta en el siguiente ciclo.""" + if self._refreshing: + self._autorefresh_id = self.root.after( + self._INTERVALO_REFRESCO_MS, self._autorefresh_tick + ) + return + + self._refreshing = True + + def tarea(): + try: + correos = self.cliente.obtener_bandeja() + self.root.after(0, lambda: self._fin_autorefresh(correos)) + except Exception: + self.root.after(0, lambda: self._fin_autorefresh(None)) + + threading.Thread(target=tarea, daemon=True).start() + + def _fin_autorefresh(self, correos): + """Siempre se ejecuta en el hilo principal.""" + self._refreshing = False + if correos is not None: + self._poblar_bandeja(correos) + # Programar siguiente tick desde el hilo principal + self._autorefresh_id = self.root.after( + self._INTERVALO_REFRESCO_MS, self._autorefresh_tick + ) + + def _detener_autorefresh(self): + if self._autorefresh_id is not None: + self.root.after_cancel(self._autorefresh_id) + self._autorefresh_id = None + self._refreshing = False + # ================================================================== # UTILIDADES # ================================================================== + def cerrar(self): + """Limpieza al cerrar la aplicación: para el auto-refresco y desconecta IMAP.""" + self._detener_autorefresh() + self.cliente.desconectar() + def _mostrar_frame(self, frame): """Levanta el frame indicado al frente.""" frame.tkraise() diff --git a/vista/chat/chat_base.py b/vista/chat/chat_base.py index 572b886..7f33eb6 100644 --- a/vista/chat/chat_base.py +++ b/vista/chat/chat_base.py @@ -12,6 +12,7 @@ class ChatBase(ttk.Frame): self.root = root self.chat_history = None self.chat_input_entry = None + self._btn_enviar = None self.socket = None def crear_interfaz_chat(self, parent_frame, titulo="Chat", boton_accion_texto=None, boton_accion_callback=None): @@ -55,10 +56,11 @@ class ChatBase(ttk.Frame): self.chat_input_entry.grid(row=0, column=0, sticky="ew", padx=(0, 5)) self.chat_input_entry.bind('', self.enviar_mensaje) - ttk.Button( + self._btn_enviar = ttk.Button( frame_input, text="Enviar", command=self.enviar_mensaje, style='Action.TButton' - ).grid(row=0, column=1, sticky="e") + ) + self._btn_enviar.grid(row=0, column=1, sticky="e") def agregar_mensaje(self, remitente, texto): """Agrega un mensaje al historial.""" @@ -76,6 +78,13 @@ class ChatBase(ttk.Frame): """Debe ser implementado por las subclases.""" raise NotImplementedError + def bloquear_entrada(self): + """Deshabilita el campo de texto y el boton de enviar.""" + if self.chat_input_entry: + self.chat_input_entry.config(state=tk.DISABLED) + if self._btn_enviar: + self._btn_enviar.config(state=tk.DISABLED) + def cerrar_conexion(self): """Cierra el socket si esta activo.""" if self.socket: diff --git a/vista/chat/chat_cliente.py b/vista/chat/chat_cliente.py index 0764853..396ae0a 100644 --- a/vista/chat/chat_cliente.py +++ b/vista/chat/chat_cliente.py @@ -1,6 +1,9 @@ +import tkinter as tk +from tkinter import ttk import threading from vista.chat.chat_base import ChatBase from logica.red.cliente import conectar_servidor +from vista.config import * PREFIJO_NOMBRE = "__NOMBRE__:" @@ -8,12 +11,13 @@ PREFIJO_NOMBRE = "__NOMBRE__:" class ChatClientePanel(ChatBase): """Vista de chat para el rol de cliente.""" - def __init__(self, parent, root, ip, puerto, clave, *args, **kwargs): + def __init__(self, parent, root, ip, puerto, clave, on_auth_error=None, *args, **kwargs): super().__init__(parent, root, *args, **kwargs) self.ip = ip self.puerto = puerto self.clave = clave self.nombre = None + self.on_auth_error = on_auth_error self.crear_interfaz_chat( self, titulo="Chat - Cliente", boton_accion_texto="Desconectar", @@ -34,7 +38,10 @@ class ChatClientePanel(ChatBase): self.recibir_mensajes(extra) else: print(f"[DEBUG CLI-GUI] Conexion fallida") - self.root.after(0, self.agregar_mensaje_sistema, "Error: no se pudo conectar o clave incorrecta") + if self.on_auth_error: + self.root.after(0, self.on_auth_error) + else: + self.root.after(0, self.agregar_mensaje_sistema, "Error: no se pudo conectar o clave incorrecta") hilo = threading.Thread(target=hilo_conexion, daemon=True) hilo.start() @@ -67,14 +74,14 @@ class ChatClientePanel(ChatBase): datos = self.socket.recv(4096) if not datos: print("[DEBUG CLI-GUI] Datos vacios recibidos (servidor cerro)") - self.root.after(0, self.agregar_mensaje_sistema, "El servidor cerro la conexion") + self.root.after(0, self._servidor_desconectado) break mensaje = datos.decode("utf-8") print(f"[DEBUG CLI-GUI] Datos crudos recibidos: {datos!r}") self._procesar_mensaje(mensaje) except (ConnectionResetError, OSError) as e: print(f"[DEBUG CLI-GUI] Error en recepcion: {e}") - self.root.after(0, self.agregar_mensaje_sistema, "Conexion perdida con el servidor") + self.root.after(0, self._servidor_desconectado) def _actualizar_titulo(self): if self.nombre: @@ -97,6 +104,46 @@ class ChatClientePanel(ChatBase): return "break" if event else None + def _servidor_desconectado(self): + """Bloquea la entrada y muestra popup cuando el servidor cierra la conexion.""" + self.cerrar_conexion() + self.bloquear_entrada() + + dialogo = tk.Toplevel(self.root) + dialogo.title("Servidor desconectado") + dialogo.geometry("320x140") + dialogo.resizable(False, False) + dialogo.transient(self.root) + dialogo.grab_set() + + frame = ttk.Frame(dialogo, padding=20) + frame.pack(expand=True, fill="both") + + ttk.Label( + frame, text="El servidor se ha cerrado.", + font=FUENTE_NEGOCIOS + ).pack(pady=(0, 15)) + + frame_btns = ttk.Frame(frame) + frame_btns.pack() + + def volver(): + dialogo.destroy() + self.desconectar_y_volver() + + def ver_chat(): + dialogo.destroy() + + ttk.Button( + frame_btns, text="Volver al inicio", + command=volver, style='Action.TButton' + ).pack(side="left", padx=5) + + ttk.Button( + frame_btns, text="Ver el chat", + command=ver_chat + ).pack(side="left", padx=5) + def desconectar_y_volver(self): """Desconecta del servidor y vuelve al selector.""" self.cerrar_conexion() diff --git a/vista/chat/chat_selector.py b/vista/chat/chat_selector.py index 7c225fe..ca88892 100644 --- a/vista/chat/chat_selector.py +++ b/vista/chat/chat_selector.py @@ -1,5 +1,5 @@ import tkinter as tk -from tkinter import ttk +from tkinter import ttk, messagebox import threading from vista.config import * from logica.red.cliente import descubrir_servidores @@ -7,6 +7,14 @@ from logica.red.selector import obtener_ip_local from vista.chat.chat_servidor import ChatServidorPanel from vista.chat.chat_cliente import ChatClientePanel +_DONUT_SIZE = 72 +_DONUT_RADIUS = 26 +_DONUT_GROSOR = 9 +_DONUT_COLOR = COLOR_ACCION +_DONUT_TRACK = "#d0d0d0" +_DONUT_STEP = 9 # grados por frame +_DONUT_MS = 25 # ms entre frames + class ChatSelectorPanel(ttk.Frame): """Panel inicial que permite elegir entre servidor o cliente.""" @@ -17,14 +25,27 @@ class ChatSelectorPanel(ttk.Frame): self.parent_notebook = parent_notebook self.panel_activo = None self._busqueda_cancelada = False + self._stop_event = None + self._donut_animating = False + self._donut_angle = 0 + self.servidores_encontrados = [] + self._ultimo_visto = {} # (ip, puerto) -> timestamp + self._frames_servidores = {} # (ip, puerto) -> ttk.Frame + self._frame_servidores = None + self._lbl_contador = None + self._lbl_buscando = None + self._canvas_donut = None + self._btn_cancelar = None self.crear_selector() + # ------------------------------------------------------------------ + # Pantalla principal + # ------------------------------------------------------------------ + def crear_selector(self): - """Crea la pantalla de seleccion de rol.""" self.selector_frame = ttk.Frame(self, padding=30, style='TFrame') self.selector_frame.pack(expand=True, fill="both") - # Titulo ttk.Label( self.selector_frame, text="Chat en Red", font=FUENTE_TITULO @@ -36,7 +57,6 @@ class ChatSelectorPanel(ttk.Frame): font=FUENTE_NOTA ).pack(pady=(0, 20)) - # Botones de rol frame_botones = ttk.Frame(self.selector_frame, style='TFrame') frame_botones.pack(pady=10) @@ -50,106 +70,299 @@ class ChatSelectorPanel(ttk.Frame): command=self.iniciar_busqueda_cliente, style='Action.TButton' ).pack(side="left", padx=10) - # Frame para resultado de busqueda (cliente) self.frame_cliente = ttk.Frame(self.selector_frame, style='TFrame') - self.estado_label = None - self.servidores_encontrados = [] + + # ------------------------------------------------------------------ + # Servidor + # ------------------------------------------------------------------ def iniciar_servidor(self): - """Reemplaza el selector por el panel de chat servidor.""" self.selector_frame.destroy() self.panel_activo = ChatServidorPanel(self, self.root) self.panel_activo.pack(expand=True, fill="both") - def iniciar_busqueda_cliente(self): - """Muestra progreso y busca servidores.""" - self._busqueda_cancelada = False + # ------------------------------------------------------------------ + # Busqueda de servidores — entrada + # ------------------------------------------------------------------ + + def iniciar_busqueda_cliente(self): + self._busqueda_cancelada = False + self.servidores_encontrados = [] + self._ultimo_visto = {} + self._frames_servidores = {} + self._stop_event = threading.Event() - # Limpiar frame cliente previo for widget in self.frame_cliente.winfo_children(): widget.destroy() self.frame_cliente.pack(pady=20, fill="x") - self.estado_label = ttk.Label( - self.frame_cliente, text="Buscando servidores...", - font=FUENTE_NEGOCIOS + # Donut canvas + self._canvas_donut = tk.Canvas( + self.frame_cliente, + width=_DONUT_SIZE, height=_DONUT_SIZE, + bg=COLOR_FONDO, highlightthickness=0 ) - self.estado_label.pack() + self._canvas_donut.pack(pady=(10, 2)) - self.progress = ttk.Progressbar(self.frame_cliente, mode='indeterminate') - self.progress.pack(fill="x", padx=40, pady=5) - self.progress.start(15) + self._lbl_buscando = ttk.Label( + self.frame_cliente, text="Buscando...", + font=FUENTE_NOTA + ) + self._lbl_buscando.pack() - ttk.Button( - self.frame_cliente, text="Cancelar busqueda", + # Contador de servidores + self._lbl_contador = ttk.Label( + self.frame_cliente, text="", font=FUENTE_NOTA + ) + self._lbl_contador.pack(pady=(6, 0)) + + # Lista de servidores (se rellena en tiempo real) + self._frame_servidores = ttk.Frame(self.frame_cliente, style='TFrame') + self._frame_servidores.pack(pady=4, fill="x") + + # Boton cancelar + self._btn_cancelar = ttk.Button( + self.frame_cliente, text="Cancelar búsqueda", command=self.cancelar_busqueda - ).pack(pady=5) + ) + self._btn_cancelar.pack(pady=5) - hilo = threading.Thread(target=self.buscar_servidores, daemon=True) + self._donut_angle = 0 + self._donut_animating = True + self._animar_donut() + + hilo = threading.Thread(target=self._hilo_busqueda, daemon=True) hilo.start() + self.root.after(2000, self._chequear_servidores_caidos) + + # ------------------------------------------------------------------ + # Animacion donut + # ------------------------------------------------------------------ + + def _animar_donut(self): + if not self._donut_animating: + return + try: + if not self._canvas_donut.winfo_exists(): + return + except tk.TclError: + return + cx = cy = _DONUT_SIZE // 2 + r = _DONUT_RADIUS + x0, y0 = cx - r, cy - r + x1, y1 = cx + r, cy + r + + self._canvas_donut.delete("all") + # Track + self._canvas_donut.create_arc( + x0, y0, x1, y1, + start=0, extent=359.9, + style=tk.ARC, outline=_DONUT_TRACK, width=_DONUT_GROSOR + ) + # Arco animado + self._canvas_donut.create_arc( + x0, y0, x1, y1, + start=self._donut_angle, extent=90, + style=tk.ARC, outline=_DONUT_COLOR, width=_DONUT_GROSOR + ) + self._donut_angle = (self._donut_angle + _DONUT_STEP) % 360 + self.root.after(_DONUT_MS, self._animar_donut) + + def _detener_donut(self): + self._donut_animating = False + + # ------------------------------------------------------------------ + # Hilo de busqueda + # ------------------------------------------------------------------ + + def _hilo_busqueda(self): + descubrir_servidores( + timeout_sin_nuevos=10, + callback=lambda ip, puerto: self.root.after( + 0, self._agregar_servidor, ip, puerto + ), + on_seen=lambda ip, puerto: self.root.after( + 0, self._actualizar_visto, ip, puerto + ), + stop_event=self._stop_event, + ) + if not self._busqueda_cancelada: + self.root.after(0, self._busqueda_finalizada) + + # ------------------------------------------------------------------ + # Callbacks de UI + # ------------------------------------------------------------------ + + _TIMEOUT_SERVIDOR = 8 # segundos sin broadcast → servidor caído + + def _agregar_servidor(self, ip, puerto): + """Añade un servidor a la lista en tiempo real. + + Si llega 127.0.0.1 y ya hay una IP LAN con el mismo puerto, lo ignora. + Si llega una IP LAN y ya hay 127.0.0.1 con ese puerto, reemplaza la entrada. + """ + import time + LOOPBACK = "127.0.0.1" + + # Buscar si ya existe una entrada con el mismo puerto + clave_existente = next( + (k for k in self.servidores_encontrados if k[1] == puerto), + None + ) + + if clave_existente is not None: + ip_existente = clave_existente[0] + if ip == LOOPBACK: + return # Ya hay IP LAN → ignorar loopback + if ip_existente == LOOPBACK: + # Reemplazar loopback por IP LAN + nueva_clave = (ip, puerto) + self.servidores_encontrados.remove(clave_existente) + self.servidores_encontrados.append(nueva_clave) + frame_srv = self._frames_servidores.pop(clave_existente) + self._frames_servidores[nueva_clave] = frame_srv + self._ultimo_visto[nueva_clave] = time.time() + del self._ultimo_visto[clave_existente] + for w in frame_srv.winfo_children(): + if isinstance(w, ttk.Label): + w.config(text=ip) + break + return + return # Mismo puerto, IPs distintas no-loopback → ya existe + + entrada = (ip, puerto) + self.servidores_encontrados.append(entrada) + self._ultimo_visto[entrada] = time.time() + + frame_srv = ttk.Frame(self._frame_servidores, style='TFrame') + frame_srv.pack(pady=2, fill="x", padx=40) + self._frames_servidores[entrada] = frame_srv + + ttk.Label( + frame_srv, text=ip, font=FUENTE_NORMAL + ).pack(side="left", padx=5) + + ttk.Button( + frame_srv, text="Conectar", + command=lambda e=entrada: self.pedir_clave_entrada(e), + style='Action.TButton' + ).pack(side="left", padx=5) + + self._actualizar_contador() + + def _actualizar_visto(self, ip, puerto): + """Actualiza el timestamp de último broadcast recibido para un servidor.""" + import time + # Buscar por puerto (independientemente de si es loopback o LAN) + clave = next( + (k for k in self.servidores_encontrados if k[1] == puerto), + None + ) + if clave: + self._ultimo_visto[clave] = time.time() + + def _chequear_servidores_caidos(self): + """Elimina de la lista los servidores que llevan más de _TIMEOUT_SERVIDOR sin broadcast.""" + import time + if self._busqueda_cancelada: + return + + try: + if not self.frame_cliente.winfo_exists(): + return + except tk.TclError: + return + + ahora = time.time() + caidos = [ + k for k in list(self.servidores_encontrados) + if ahora - self._ultimo_visto.get(k, ahora) > self._TIMEOUT_SERVIDOR + ] + + for clave in caidos: + self.servidores_encontrados.remove(clave) + del self._ultimo_visto[clave] + frame = self._frames_servidores.pop(clave, None) + if frame and frame.winfo_exists(): + frame.destroy() + + if caidos: + self._actualizar_contador() + + self.root.after(2000, self._chequear_servidores_caidos) + + def _actualizar_contador(self): + if not self._lbl_contador or not self._lbl_contador.winfo_exists(): + return + n = len(self.servidores_encontrados) + self._lbl_contador.config( + text=f"{n} servidor{'es' if n != 1 else ''} encontrado{'s' if n != 1 else ''}" + ) + + def _busqueda_finalizada(self): + self._detener_donut() + + try: + if not self.frame_cliente.winfo_exists(): + return + except tk.TclError: + return + + # Ocultar donut y texto "Buscando..." + if self._canvas_donut and self._canvas_donut.winfo_exists(): + self._canvas_donut.destroy() + if self._lbl_buscando and self._lbl_buscando.winfo_exists(): + self._lbl_buscando.destroy() + + if self._btn_cancelar and self._btn_cancelar.winfo_exists(): + self._btn_cancelar.destroy() + + if not self.servidores_encontrados: + if self._lbl_contador and self._lbl_contador.winfo_exists(): + self._lbl_contador.config( + text="No se encontraron servidores.", foreground="red" + ) + else: + if self._lbl_contador and self._lbl_contador.winfo_exists(): + n = len(self.servidores_encontrados) + self._lbl_contador.config( + text=f"Búsqueda completada — {n} servidor{'es' if n != 1 else ''} encontrado{'s' if n != 1 else ''}", + foreground=COLOR_EXITO + ) + + frame_acciones = ttk.Frame(self.frame_cliente, style='TFrame') + frame_acciones.pack(pady=8) + + ttk.Button( + frame_acciones, text="Actualizar", + command=self.iniciar_busqueda_cliente, style='Action.TButton' + ).pack(side="left", padx=5) + + ttk.Button( + frame_acciones, text="Volver", + command=self.volver_selector + ).pack(side="left", padx=5) + + # ------------------------------------------------------------------ + # Cancelar / volver + # ------------------------------------------------------------------ + def cancelar_busqueda(self): - """Cancela la busqueda de servidores.""" self._busqueda_cancelada = True - self.progress.stop() + if self._stop_event: + self._stop_event.set() + self._detener_donut() for widget in self.frame_cliente.winfo_children(): widget.destroy() self.frame_cliente.pack_forget() - def buscar_servidores(self): - print("[DEBUG SEL] Iniciando busqueda de servidores...") - servidores = descubrir_servidores(timeout=5) - print(f"[DEBUG SEL] Busqueda completada: {servidores}") - if not self._busqueda_cancelada: - self.root.after(0, self.mostrar_resultado_busqueda, servidores) - - def mostrar_resultado_busqueda(self, servidores): - self.progress.stop() - for widget in self.frame_cliente.winfo_children(): - widget.destroy() - - if not servidores: - ttk.Label( - self.frame_cliente, text="No se encontraron servidores.", - font=FUENTE_NEGOCIOS, foreground="red" - ).pack() - - frame_opciones = ttk.Frame(self.frame_cliente, style='TFrame') - frame_opciones.pack(pady=10) - ttk.Button( - frame_opciones, text="Reintentar", - command=self.iniciar_busqueda_cliente, style='Action.TButton' - ).pack(side="left", padx=5) - ttk.Button( - frame_opciones, text="Volver", - command=self.volver_selector - ).pack(side="left", padx=5) - return - - self.servidores_encontrados = servidores - - ttk.Label( - self.frame_cliente, - text=f"{len(servidores)} servidor(es) encontrado(s):", - font=FUENTE_NEGOCIOS - ).pack() - - for i, (ip, puerto) in enumerate(servidores): - frame_srv = ttk.Frame(self.frame_cliente, style='TFrame') - frame_srv.pack(pady=2) - ttk.Label(frame_srv, text=f"{ip}", font=FUENTE_NORMAL).pack(side="left", padx=5) - ttk.Button( - frame_srv, text="Conectar", - command=lambda idx=i: self.pedir_clave(idx), style='Action.TButton' - ).pack(side="left", padx=5) - - def pedir_clave(self, indice): - """Muestra un dialogo para introducir la contrasena.""" - ip, puerto = self.servidores_encontrados[indice] + def pedir_clave_entrada(self, srv_entrada): + ip, puerto = srv_entrada dialogo = tk.Toplevel(self.root) dialogo.title("Clave de acceso") - dialogo.geometry("350x120") + dialogo.geometry("350x160") dialogo.resizable(False, False) dialogo.transient(self.root) dialogo.grab_set() @@ -174,20 +387,34 @@ class ChatSelectorPanel(ttk.Frame): ttk.Button(frame, text="Conectar", command=conectar, style='Action.TButton').pack() def conectar_a_servidor(self, ip, puerto, contrasena): - """Reemplaza el selector por el panel de chat cliente.""" + # Detener busqueda antes de destruir widgets + self._busqueda_cancelada = True + if self._stop_event: + self._stop_event.set() + self._detener_donut() + print(f"[DEBUG SEL] Conectando a servidor: ip={ip} puerto={puerto} clave={contrasena!r}") self.selector_frame.destroy() - self.panel_activo = ChatClientePanel(self, self.root, ip, puerto, contrasena) + self.panel_activo = ChatClientePanel( + self, self.root, ip, puerto, contrasena, + on_auth_error=self._auth_fallida + ) self.panel_activo.pack(expand=True, fill="both") + def _auth_fallida(self): + """Destruye el panel de chat y vuelve al selector con un aviso de error.""" + if self.panel_activo: + self.panel_activo.destroy() + self.panel_activo = None + self.crear_selector() + messagebox.showerror("Contraseña incorrecta", "La clave introducida no es válida.\nComprueba el formato: puerto#contraseña") + def volver_al_selector(self): - """Destruye el panel activo y recrea el selector.""" if self.panel_activo: self.panel_activo = None self.crear_selector() def volver_selector(self): - """Limpia el frame de cliente para volver al estado inicial.""" for widget in self.frame_cliente.winfo_children(): widget.destroy() self.frame_cliente.pack_forget() diff --git a/vista/ventana_principal.py b/vista/ventana_principal.py index 72e7056..bdf9647 100644 --- a/vista/ventana_principal.py +++ b/vista/ventana_principal.py @@ -171,10 +171,14 @@ class VentanaPrincipal(tk.Tk): self.detener_actualizacion_reloj() self.detener_actualizacion_clima() - # Detiene el hilo de TrafficMeter y el ciclo de repintado del gráfico if self.panel_central: self.panel_central.detener_actualizacion_automatica() + # Desconectar correo si hay sesión activa + panel_correos = self.panel_central.modulos.get("Correos") + if panel_correos: + panel_correos.cerrar() + self.destroy() print("Aplicación cerrada limpiamente.")