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):
"""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")

View File

@ -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()

View File

@ -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

View File

@ -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("<Double-1>", 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()

View File

@ -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('<Return>', 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:

View File

@ -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()

View File

@ -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()

View File

@ -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.")