update: mejoras en chat, correo y red cliente/selector

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Olivia Mestre Llobell 2026-02-23 18:44:20 +01:00
parent f916e78fe2
commit 84148e6c0e
8 changed files with 544 additions and 117 deletions

View File

@ -99,6 +99,7 @@ class CorreoClient:
def obtener_bandeja(self): def obtener_bandeja(self):
"""Lista correos del INBOX. Devuelve lista de dicts con uid, de, asunto, fecha.""" """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") self.imap.select("INBOX")
status, data = self.imap.uid("search", None, "ALL") status, data = self.imap.uid("search", None, "ALL")
if status != "OK": if status != "OK":
@ -108,10 +109,13 @@ class CorreoClient:
correos = [] correos = []
for uid in reversed(uids[-100:]): # Últimos 100, más recientes primero 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]: if status != "OK" or not msg_data[0]:
continue continue
info_raw = msg_data[0][0].decode("utf-8", errors="replace")
leido = "\\Seen" in info_raw
raw = msg_data[0][1] raw = msg_data[0][1]
msg = email.message_from_bytes(raw) msg = email.message_from_bytes(raw)
@ -124,10 +128,19 @@ class CorreoClient:
"de": de, "de": de,
"asunto": asunto, "asunto": asunto,
"fecha": fecha, "fecha": fecha,
"leido": leido,
}) })
return correos 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): def leer_correo(self, uid):
"""Lee el contenido completo de un correo por UID. Devuelve texto plano.""" """Lee el contenido completo de un correo por UID. Devuelve texto plano."""
self.imap.select("INBOX") self.imap.select("INBOX")

View File

@ -3,18 +3,23 @@ import socket
from logica.red.servidor import APP_FIRMA, PUERTO_BROADCAST 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. """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 = [] servidores = []
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
if hasattr(socket, "SO_REUSEPORT"): if hasattr(socket, "SO_REUSEPORT"):
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
sock.settimeout(timeout) sock.settimeout(1)
try: try:
sock.bind(("", PUERTO_BROADCAST)) sock.bind(("", PUERTO_BROADCAST))
print(f"[DEBUG CLI] Socket UDP enlazado a '':{PUERTO_BROADCAST}") print(f"[DEBUG CLI] Socket UDP enlazado a '':{PUERTO_BROADCAST}")
@ -23,23 +28,37 @@ def descubrir_servidores(timeout=5):
sock.close() sock.close()
return servidores return servidores
ultimo_encontrado = time.time()
try: try:
while True: while True:
datos, direccion = sock.recvfrom(1024) if stop_event and stop_event.is_set():
mensaje = datos.decode("utf-8") print(f"[DEBUG CLI] Busqueda cancelada externamente")
print(f"[DEBUG CLI] Paquete UDP recibido de {direccion}: {mensaje!r}") break
if verificar_firma(mensaje): if time.time() - ultimo_encontrado >= timeout_sin_nuevos:
partes = mensaje.split("|") print(f"[DEBUG CLI] {timeout_sin_nuevos}s sin nuevos servidores, finalizando")
if len(partes) == 2: break
puerto_servidor = int(partes[1]) try:
entrada = (direccion[0], puerto_servidor) datos, direccion = sock.recvfrom(1024)
if entrada not in servidores: mensaje = datos.decode("utf-8")
servidores.append(entrada) print(f"[DEBUG CLI] Paquete UDP recibido de {direccion}: {mensaje!r}")
print(f"[DEBUG CLI] Servidor descubierto: {entrada[0]}:{entrada[1]}") if verificar_firma(mensaje):
else: partes = mensaje.split("|")
print(f"[DEBUG CLI] Firma no valida, ignorado") if len(partes) == 2:
except socket.timeout: puerto_servidor = int(partes[1])
print(f"[DEBUG CLI] Timeout alcanzado") 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: finally:
sock.close() sock.close()

View File

@ -20,6 +20,10 @@ def obtener_ip_local():
return "127.0.0.1" return "127.0.0.1"
_clientes = [] # lista de (conn, addr)
_clientes_lock = threading.Lock()
def modo_servidor(): def modo_servidor():
servidor, puerto, contrasena_alfa, _broadcast_stop = iniciar_servidor() servidor, puerto, contrasena_alfa, _broadcast_stop = iniciar_servidor()
clave_acceso = f"{puerto}#{contrasena_alfa}" clave_acceso = f"{puerto}#{contrasena_alfa}"
@ -27,8 +31,22 @@ def modo_servidor():
print(f"[SERVIDOR] IP: {ip}") print(f"[SERVIDOR] IP: {ip}")
print(f"[SERVIDOR] Escuchando en puerto {puerto}") print(f"[SERVIDOR] Escuchando en puerto {puerto}")
print(f"[SERVIDOR] Clave de acceso: {clave_acceso}") 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: try:
while True: while True:
conn, addr = servidor.accept() conn, addr = servidor.accept()
@ -36,18 +54,19 @@ def modo_servidor():
if autenticar_cliente(datos, clave_acceso): if autenticar_cliente(datos, clave_acceso):
conn.sendall("OK".encode("utf-8")) conn.sendall("OK".encode("utf-8"))
print(f"[SERVIDOR] Cliente {addr[0]}:{addr[1]} autenticado") 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() hilo.start()
else: else:
conn.sendall("DENIED".encode("utf-8")) conn.sendall("DENIED".encode("utf-8"))
conn.close() conn.close()
print(f"[SERVIDOR] Cliente {addr[0]}:{addr[1]} rechazado (clave incorrecta)") print(f"[SERVIDOR] Cliente {addr[0]}:{addr[1]} rechazado (clave incorrecta)")
except KeyboardInterrupt: except OSError:
print("\n[SERVIDOR] Cerrando...") pass
servidor.close()
def manejar_cliente(conn, addr): def _manejar_cliente(conn, addr):
etiqueta = f"{addr[0]}:{addr[1]}" etiqueta = f"{addr[0]}:{addr[1]}"
try: try:
while True: while True:
@ -56,14 +75,27 @@ def manejar_cliente(conn, addr):
break break
mensaje = datos.decode("utf-8") mensaje = datos.decode("utf-8")
print(f"[{etiqueta}] {mensaje}") print(f"[{etiqueta}] {mensaje}")
conn.sendall(f"Echo: {mensaje}".encode("utf-8")) _broadcast(f"{etiqueta}: {mensaje}", origen=conn)
except (ConnectionResetError, OSError): except (ConnectionResetError, OSError):
pass pass
finally: finally:
with _clientes_lock:
_clientes[:] = [(c, a) for c, a in _clientes if c is not conn]
print(f"[SERVIDOR] Cliente {etiqueta} desconectado") print(f"[SERVIDOR] Cliente {etiqueta} desconectado")
conn.close() 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(): def buscar_servidores():
"""Busca servidores con reintentos. Retorna lista de servidores o None.""" """Busca servidores con reintentos. Retorna lista de servidores o None."""
MAX_INTENTOS = 3 MAX_INTENTOS = 3

View File

@ -18,6 +18,8 @@ class CorreosPanel(ttk.Frame):
self.root = root self.root = root
self.cliente = CorreoClient() self.cliente = CorreoClient()
self.correos_cache = [] self.correos_cache = []
self._autorefresh_id = None
self._refreshing = False
# Frames apilados (login, bandeja, redactar) # Frames apilados (login, bandeja, redactar)
self.frame_login = ttk.Frame(self) self.frame_login = ttk.Frame(self)
@ -207,6 +209,7 @@ class CorreosPanel(ttk.Frame):
self.lbl_usuario.config(text=f" {user}") self.lbl_usuario.config(text=f" {user}")
self._poblar_bandeja(correos) self._poblar_bandeja(correos)
self._mostrar_frame(self.frame_bandeja) self._mostrar_frame(self.frame_bandeja)
self._iniciar_autorefresh()
def _conexion_fallida(self, error): def _conexion_fallida(self, error):
self.btn_conectar.config(state="normal") self.btn_conectar.config(state="normal")
@ -253,6 +256,9 @@ class CorreosPanel(ttk.Frame):
self.tree.pack(side="left", fill="both", expand=True) self.tree.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y") 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("<Double-1>", self._on_doble_clic_correo) self.tree.bind("<Double-1>", self._on_doble_clic_correo)
# Área de lectura de 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)) self.txt_lectura.pack(fill="both", expand=True, padx=10, pady=(0, 10))
def _poblar_bandeja(self, correos): 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 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()) self.tree.delete(*self.tree.get_children())
for c in correos: for c in correos:
tag = "leido" if c.get("leido", True) else "no_leido"
self.tree.insert("", "end", iid=c["uid"], 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): 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() sel = self.tree.selection()
if not sel: if not sel:
return return
uid = sel[0] 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.config(state="normal")
self.txt_lectura.delete("1.0", "end") self.txt_lectura.delete("1.0", "end")
self.txt_lectura.insert("1.0", "Cargando...") self.txt_lectura.insert("1.0", "Cargando...")
@ -286,6 +308,7 @@ class CorreosPanel(ttk.Frame):
def tarea(): def tarea():
try: try:
contenido = self.cliente.leer_correo(uid) contenido = self.cliente.leer_correo(uid)
self.cliente.marcar_leido(uid)
self.root.after(0, lambda: self._mostrar_contenido(contenido)) self.root.after(0, lambda: self._mostrar_contenido(contenido))
except Exception as e: except Exception as e:
self.root.after(0, lambda: self._mostrar_contenido(f"Error: {e}")) self.root.after(0, lambda: self._mostrar_contenido(f"Error: {e}"))
@ -311,6 +334,7 @@ class CorreosPanel(ttk.Frame):
def _on_logout(self): def _on_logout(self):
"""Cierra sesión y vuelve al login.""" """Cierra sesión y vuelve al login."""
self._detener_autorefresh()
self.cliente.desconectar() self.cliente.desconectar()
self.tree.delete(*self.tree.get_children()) self.tree.delete(*self.tree.get_children())
self.txt_lectura.config(state="normal") self.txt_lectura.config(state="normal")
@ -409,10 +433,62 @@ class CorreosPanel(ttk.Frame):
self.btn_enviar.config(state="normal") self.btn_enviar.config(state="normal")
self.lbl_estado_envio.config(text=f"Error: {error}", foreground="red") 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 # 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): def _mostrar_frame(self, frame):
"""Levanta el frame indicado al frente.""" """Levanta el frame indicado al frente."""
frame.tkraise() frame.tkraise()

View File

@ -12,6 +12,7 @@ class ChatBase(ttk.Frame):
self.root = root self.root = root
self.chat_history = None self.chat_history = None
self.chat_input_entry = None self.chat_input_entry = None
self._btn_enviar = None
self.socket = None self.socket = None
def crear_interfaz_chat(self, parent_frame, titulo="Chat", boton_accion_texto=None, boton_accion_callback=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.grid(row=0, column=0, sticky="ew", padx=(0, 5))
self.chat_input_entry.bind('<Return>', self.enviar_mensaje) self.chat_input_entry.bind('<Return>', self.enviar_mensaje)
ttk.Button( self._btn_enviar = ttk.Button(
frame_input, text="Enviar", frame_input, text="Enviar",
command=self.enviar_mensaje, style='Action.TButton' 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): def agregar_mensaje(self, remitente, texto):
"""Agrega un mensaje al historial.""" """Agrega un mensaje al historial."""
@ -76,6 +78,13 @@ class ChatBase(ttk.Frame):
"""Debe ser implementado por las subclases.""" """Debe ser implementado por las subclases."""
raise NotImplementedError 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): def cerrar_conexion(self):
"""Cierra el socket si esta activo.""" """Cierra el socket si esta activo."""
if self.socket: if self.socket:

View File

@ -1,6 +1,9 @@
import tkinter as tk
from tkinter import ttk
import threading import threading
from vista.chat.chat_base import ChatBase from vista.chat.chat_base import ChatBase
from logica.red.cliente import conectar_servidor from logica.red.cliente import conectar_servidor
from vista.config import *
PREFIJO_NOMBRE = "__NOMBRE__:" PREFIJO_NOMBRE = "__NOMBRE__:"
@ -8,12 +11,13 @@ PREFIJO_NOMBRE = "__NOMBRE__:"
class ChatClientePanel(ChatBase): class ChatClientePanel(ChatBase):
"""Vista de chat para el rol de cliente.""" """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) super().__init__(parent, root, *args, **kwargs)
self.ip = ip self.ip = ip
self.puerto = puerto self.puerto = puerto
self.clave = clave self.clave = clave
self.nombre = None self.nombre = None
self.on_auth_error = on_auth_error
self.crear_interfaz_chat( self.crear_interfaz_chat(
self, titulo="Chat - Cliente", self, titulo="Chat - Cliente",
boton_accion_texto="Desconectar", boton_accion_texto="Desconectar",
@ -34,7 +38,10 @@ class ChatClientePanel(ChatBase):
self.recibir_mensajes(extra) self.recibir_mensajes(extra)
else: else:
print(f"[DEBUG CLI-GUI] Conexion fallida") 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 = threading.Thread(target=hilo_conexion, daemon=True)
hilo.start() hilo.start()
@ -67,14 +74,14 @@ class ChatClientePanel(ChatBase):
datos = self.socket.recv(4096) datos = self.socket.recv(4096)
if not datos: if not datos:
print("[DEBUG CLI-GUI] Datos vacios recibidos (servidor cerro)") 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 break
mensaje = datos.decode("utf-8") mensaje = datos.decode("utf-8")
print(f"[DEBUG CLI-GUI] Datos crudos recibidos: {datos!r}") print(f"[DEBUG CLI-GUI] Datos crudos recibidos: {datos!r}")
self._procesar_mensaje(mensaje) self._procesar_mensaje(mensaje)
except (ConnectionResetError, OSError) as e: except (ConnectionResetError, OSError) as e:
print(f"[DEBUG CLI-GUI] Error en recepcion: {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): def _actualizar_titulo(self):
if self.nombre: if self.nombre:
@ -97,6 +104,46 @@ class ChatClientePanel(ChatBase):
return "break" if event else None 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): def desconectar_y_volver(self):
"""Desconecta del servidor y vuelve al selector.""" """Desconecta del servidor y vuelve al selector."""
self.cerrar_conexion() self.cerrar_conexion()

View File

@ -1,5 +1,5 @@
import tkinter as tk import tkinter as tk
from tkinter import ttk from tkinter import ttk, messagebox
import threading import threading
from vista.config import * from vista.config import *
from logica.red.cliente import descubrir_servidores 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_servidor import ChatServidorPanel
from vista.chat.chat_cliente import ChatClientePanel 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): class ChatSelectorPanel(ttk.Frame):
"""Panel inicial que permite elegir entre servidor o cliente.""" """Panel inicial que permite elegir entre servidor o cliente."""
@ -17,14 +25,27 @@ class ChatSelectorPanel(ttk.Frame):
self.parent_notebook = parent_notebook self.parent_notebook = parent_notebook
self.panel_activo = None self.panel_activo = None
self._busqueda_cancelada = False 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() self.crear_selector()
# ------------------------------------------------------------------
# Pantalla principal
# ------------------------------------------------------------------
def crear_selector(self): def crear_selector(self):
"""Crea la pantalla de seleccion de rol."""
self.selector_frame = ttk.Frame(self, padding=30, style='TFrame') self.selector_frame = ttk.Frame(self, padding=30, style='TFrame')
self.selector_frame.pack(expand=True, fill="both") self.selector_frame.pack(expand=True, fill="both")
# Titulo
ttk.Label( ttk.Label(
self.selector_frame, text="Chat en Red", self.selector_frame, text="Chat en Red",
font=FUENTE_TITULO font=FUENTE_TITULO
@ -36,7 +57,6 @@ class ChatSelectorPanel(ttk.Frame):
font=FUENTE_NOTA font=FUENTE_NOTA
).pack(pady=(0, 20)) ).pack(pady=(0, 20))
# Botones de rol
frame_botones = ttk.Frame(self.selector_frame, style='TFrame') frame_botones = ttk.Frame(self.selector_frame, style='TFrame')
frame_botones.pack(pady=10) frame_botones.pack(pady=10)
@ -50,106 +70,299 @@ class ChatSelectorPanel(ttk.Frame):
command=self.iniciar_busqueda_cliente, style='Action.TButton' command=self.iniciar_busqueda_cliente, style='Action.TButton'
).pack(side="left", padx=10) ).pack(side="left", padx=10)
# Frame para resultado de busqueda (cliente)
self.frame_cliente = ttk.Frame(self.selector_frame, style='TFrame') self.frame_cliente = ttk.Frame(self.selector_frame, style='TFrame')
self.estado_label = None
self.servidores_encontrados = [] # ------------------------------------------------------------------
# Servidor
# ------------------------------------------------------------------
def iniciar_servidor(self): def iniciar_servidor(self):
"""Reemplaza el selector por el panel de chat servidor."""
self.selector_frame.destroy() self.selector_frame.destroy()
self.panel_activo = ChatServidorPanel(self, self.root) self.panel_activo = ChatServidorPanel(self, self.root)
self.panel_activo.pack(expand=True, fill="both") self.panel_activo.pack(expand=True, fill="both")
def iniciar_busqueda_cliente(self): # ------------------------------------------------------------------
"""Muestra progreso y busca servidores.""" # Busqueda de servidores — entrada
self._busqueda_cancelada = False # ------------------------------------------------------------------
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(): for widget in self.frame_cliente.winfo_children():
widget.destroy() widget.destroy()
self.frame_cliente.pack(pady=20, fill="x") self.frame_cliente.pack(pady=20, fill="x")
self.estado_label = ttk.Label( # Donut canvas
self.frame_cliente, text="Buscando servidores...", self._canvas_donut = tk.Canvas(
font=FUENTE_NEGOCIOS 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._lbl_buscando = ttk.Label(
self.progress.pack(fill="x", padx=40, pady=5) self.frame_cliente, text="Buscando...",
self.progress.start(15) font=FUENTE_NOTA
)
self._lbl_buscando.pack()
ttk.Button( # Contador de servidores
self.frame_cliente, text="Cancelar busqueda", 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 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() 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): def cancelar_busqueda(self):
"""Cancela la busqueda de servidores."""
self._busqueda_cancelada = True 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(): for widget in self.frame_cliente.winfo_children():
widget.destroy() widget.destroy()
self.frame_cliente.pack_forget() self.frame_cliente.pack_forget()
def buscar_servidores(self): def pedir_clave_entrada(self, srv_entrada):
print("[DEBUG SEL] Iniciando busqueda de servidores...") ip, puerto = srv_entrada
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]
dialogo = tk.Toplevel(self.root) dialogo = tk.Toplevel(self.root)
dialogo.title("Clave de acceso") dialogo.title("Clave de acceso")
dialogo.geometry("350x120") dialogo.geometry("350x160")
dialogo.resizable(False, False) dialogo.resizable(False, False)
dialogo.transient(self.root) dialogo.transient(self.root)
dialogo.grab_set() dialogo.grab_set()
@ -174,20 +387,34 @@ class ChatSelectorPanel(ttk.Frame):
ttk.Button(frame, text="Conectar", command=conectar, style='Action.TButton').pack() ttk.Button(frame, text="Conectar", command=conectar, style='Action.TButton').pack()
def conectar_a_servidor(self, ip, puerto, contrasena): 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}") print(f"[DEBUG SEL] Conectando a servidor: ip={ip} puerto={puerto} clave={contrasena!r}")
self.selector_frame.destroy() 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") 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): def volver_al_selector(self):
"""Destruye el panel activo y recrea el selector."""
if self.panel_activo: if self.panel_activo:
self.panel_activo = None self.panel_activo = None
self.crear_selector() self.crear_selector()
def volver_selector(self): def volver_selector(self):
"""Limpia el frame de cliente para volver al estado inicial."""
for widget in self.frame_cliente.winfo_children(): for widget in self.frame_cliente.winfo_children():
widget.destroy() widget.destroy()
self.frame_cliente.pack_forget() self.frame_cliente.pack_forget()

View File

@ -171,10 +171,14 @@ class VentanaPrincipal(tk.Tk):
self.detener_actualizacion_reloj() self.detener_actualizacion_reloj()
self.detener_actualizacion_clima() self.detener_actualizacion_clima()
# Detiene el hilo de TrafficMeter y el ciclo de repintado del gráfico
if self.panel_central: if self.panel_central:
self.panel_central.detener_actualizacion_automatica() 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() self.destroy()
print("Aplicación cerrada limpiamente.") print("Aplicación cerrada limpiamente.")