feat: complete client-server architecture refactoring

Server:
- Split into views, routes, helpers, models modules
- Merged /ws/talk and /ws/update into single /ws/chat endpoint
- Replaced polling with push-based broadcast model
- Added username uniqueness validation on connect
- Fixed run_server arguments bug (workers parameter)
- Removed deprecated loop argument from Sanic listeners
- Replaced datetime.utcnow() with timezone-aware datetime.now(timezone.utc)

Client:
- Rewrote client as single-file module
- Migrated from websocket-client to websockets (asyncio)
- Fixed websocket-client conflict with asyncio event loop on Windows
- Added progress indicators for key generation, exchange, connection
- Added animated 3D spinning cube in UI
- Updated RSA key from 512 to 2048 bits

CLI:
- Removed unnecessary asyncio.run() wrapper
- Simplified entry point
This commit is contained in:
mirai
2026-01-02 14:42:33 +03:00
parent faaadd839b
commit 95f8a192b5
22 changed files with 682 additions and 441 deletions
+1
View File
@@ -1,5 +1,6 @@
from abc import ABC, abstractmethod
class CryptoService(ABC):
@abstractmethod
+2 -3
View File
@@ -2,10 +2,9 @@ from abc import ABC, abstractmethod
class ClientRenderer(ABC):
# These attributes are expected to be provided by subclasses
# (typically via multiple inheritance with CryptoService)
username: str
@abstractmethod
def _decrypt(self, message: str) -> str:
"""Decrypt an encrypted message (provided by crypto mixin)."""
+2 -3
View File
@@ -28,17 +28,16 @@ class RSAService(CryptoService):
stream=True,
)
r.raise_for_status()
# read the full response content (server returns encrypted symmetric key)
message = r.content
self.symmetric_key = rsa.decrypt(message, self.private_key)
self.fernet = Fernet(self.symmetric_key)
def _generate_keys(self):
self.public_key, self.private_key = rsa.newkeys(512)
self.public_key, self.private_key = rsa.newkeys(2048)
def _get_generated_keys(self):
return self.private_key, self.public_key
def _remove_keys(self):
self.public_key = None
self.private_key = None
self.private_key = None
-61
View File
@@ -1,61 +0,0 @@
import os
import platform
from cmd_chat.client.core.abs.abs_renderer import ClientRenderer
from cmd_chat.client.config import COLORS
from colorama import init
init()
class DefaultClientRenderer(ClientRenderer):
def __get_os(self) -> str:
""" checking what kind of platform you need
"""
if "Linux" in str(platform.platform()):
return "Linux"
return "Windows"
def print_message(self, message: str) -> str:
""" generating string with message in required format
"""
# split only on the first ':' to keep message contents intact
parts = message.split(":", 1)
if parts[0] == self.username:
return COLORS["my_username_color"] + parts[0] + ": " + parts[1] + COLORS["text_color"]
return parts[0] + ": " + parts[1] + COLORS["text_color"]
def clear_console(self):
# For windows clear command its cls
# For linux clear command its clear
if self.__get_os() == "Linux":
os.system("clear")
else:
os.system("cls")
def print_ip(
self,
ip: str
) -> str:
return f"IP: " + COLORS["ip_color"] + ip + COLORS["text_color"]
def print_username(
self,
username: str
) -> str:
# Username label + colored username
return f"USERNAME: " + COLORS["username_color"] + username + COLORS["text_color"]
def print_chat(self, response) -> None:
for i, msg in enumerate(response["messages"]):
actual_message = self._decrypt(msg)
if i == 0:
for user in response["users_in_chat"]:
print(self.print_ip(user.split(",")[0]))
print(self.print_username(user.split(",")[1]))
print("Write 'q' to quit from chat")
print(f"\n{self.print_message(actual_message)}")
else:
print(f"{self.print_message(actual_message)}")
+22 -28
View File
@@ -1,9 +1,9 @@
import os
import os
import platform
from rich.text import Text
from rich.text import Text
from rich.style import Style
from rich.console import Console
from rich.console import Console
from rich.table import Table
from cmd_chat.client.core.abs.abs_renderer import ClientRenderer
@@ -16,26 +16,26 @@ console = Console(width=75)
class RichClientRenderer(ClientRenderer):
def __get_os(self) -> str:
""" checking what kind of platform you need
"""
"""checking what kind of platform you need"""
if "Linux" in str(platform.platform()):
return "Linux"
return "Windows"
def print_message(self, message: str) -> Text:
""" generating string with message in required format
"""
"""generating string with message in required format"""
# split only on the first ':' so message bodies containing ':' are preserved
parts = message.split(":", 1)
if parts[0] == self.username:
return \
Text(text=parts[0], style="bold") + \
Text(text=": ", style="bold") + \
Text(text=parts[1], style="underline")
return \
Text(text=parts[0], style="bold") + \
Text(text=": ", style="bold") + \
Text(text=parts[1], style="underline")
return (
Text(text=parts[0], style="bold")
+ Text(text=": ", style="bold")
+ Text(text=parts[1], style="underline")
)
return (
Text(text=parts[0], style="bold")
+ Text(text=": ", style="bold")
+ Text(text=parts[1], style="underline")
)
def clear_console(self):
# For windows clear command its cls
@@ -45,16 +45,10 @@ class RichClientRenderer(ClientRenderer):
else:
os.system("cls")
def print_ip(
self,
ip: str
) -> str:
def print_ip(self, ip: str) -> str:
return ip
def print_username(
self,
username: str
) -> str:
def print_username(self, username: str) -> str:
return username
def print_chat(self, response) -> None:
@@ -68,11 +62,11 @@ class RichClientRenderer(ClientRenderer):
table.add_column("USERNAME")
for user in response["users_in_chat"]:
table.add_row(
self.print_ip(user.split(',')[0]),
self.print_username(user.split(",")[1])
self.print_ip(user.split(",")[0]),
self.print_username(user.split(",")[1]),
)
console.print(table)
console.print("Write 'q' to quit from chat", justify="left")
console.print(f"\n{self.print_message(actual_message)}")
else:
console.print(f"{self.print_message(actual_message)}")
console.print(f"{self.print_message(actual_message)}")