update: mejoras en chat, correo y red cliente/selector
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f916e78fe2
commit
84148e6c0e
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue