From 609c627823125c93ea73363cffe070c541c2fdb8 Mon Sep 17 00:00:00 2001 From: MOJ1403 Date: Mon, 23 Mar 2026 20:28:22 +0330 Subject: [PATCH] first project like telegram --- App_GUI.py | 249 -------------- Crypto_Engine.py | 162 --------- DB_Handler.py | 134 -------- GSM_Manager.py | 218 ------------ main.py | 2 +- refactor.py | 70 ++++ secure_sms/__pycache__/ui.cpython-313.pyc | Bin 84520 -> 87075 bytes secure_sms/application/__init__.py | 0 .../__pycache__/controller.cpython-313.pyc | Bin 0 -> 10169 bytes .../__pycache__/services.cpython-313.pyc | Bin 0 -> 23440 bytes secure_sms/{ => application}/controller.py | 72 ++-- secure_sms/{ => application}/services.py | 12 +- secure_sms/core/__init__.py | 0 secure_sms/{ => core}/models.py | 0 secure_sms/{ => core}/protocol.py | 2 +- secure_sms/{ => core}/security.py | 0 secure_sms/infrastructure/__init__.py | 0 secure_sms/{ => infrastructure}/database.py | 10 +- secure_sms/{ => infrastructure}/gsm.py | 18 +- secure_sms/ui.py | 314 ++++++++++-------- secure_sms/ui/__init__.py | 0 secure_sms/ui/core.py | 108 ++++++ 22 files changed, 426 insertions(+), 945 deletions(-) delete mode 100644 App_GUI.py delete mode 100644 Crypto_Engine.py delete mode 100644 DB_Handler.py delete mode 100644 GSM_Manager.py create mode 100644 refactor.py create mode 100644 secure_sms/application/__init__.py create mode 100644 secure_sms/application/__pycache__/controller.cpython-313.pyc create mode 100644 secure_sms/application/__pycache__/services.cpython-313.pyc rename secure_sms/{ => application}/controller.py (66%) rename secure_sms/{ => application}/services.py (97%) create mode 100644 secure_sms/core/__init__.py rename secure_sms/{ => core}/models.py (100%) rename secure_sms/{ => core}/protocol.py (97%) rename secure_sms/{ => core}/security.py (100%) create mode 100644 secure_sms/infrastructure/__init__.py rename secure_sms/{ => infrastructure}/database.py (97%) rename secure_sms/{ => infrastructure}/gsm.py (93%) create mode 100644 secure_sms/ui/__init__.py create mode 100644 secure_sms/ui/core.py diff --git a/App_GUI.py b/App_GUI.py deleted file mode 100644 index 0170012..0000000 --- a/App_GUI.py +++ /dev/null @@ -1,249 +0,0 @@ -import customtkinter as ctk - -# GUI Configuration -ctk.set_appearance_mode("Dark") -ctk.set_default_color_theme("blue") - -class AppGUI(ctk.CTk): - def __init__(self, controller): - super().__init__() - self.controller = controller - - self.title("Secure SMS - Raspberry Pi") - self.geometry("800x480") # Typical Pi touchscreen resolution - - # Current state - self.current_contact = None - - self.setup_ui() - self.load_contacts() - - def setup_ui(self): - # Grid Layout (1x2) - Sidebar | Main Content - self.grid_rowconfigure(0, weight=1) - self.grid_columnconfigure(1, weight=1) - - # --- Sidebar --- - self.sidebar_frame = ctk.CTkFrame(self, width=200, corner_radius=0) - self.sidebar_frame.grid(row=0, column=0, sticky="nsew") - self.sidebar_frame.grid_rowconfigure(4, weight=1) - - self.logo_label = ctk.CTkLabel(self.sidebar_frame, text="Secure SMS", font=ctk.CTkFont(size=20, weight="bold")) - self.logo_label.grid(row=0, column=0, padx=20, pady=(20, 10)) - - self.contacts_scrollable_frame = ctk.CTkScrollableFrame(self.sidebar_frame, label_text="Contacts") - self.contacts_scrollable_frame.grid(row=1, column=0, padx=10, pady=10, sticky="nsew", rowspan=4) - - # Settings / Add Contact Button - self.settings_btn = ctk.CTkButton(self.sidebar_frame, text="Settings / Add Contact", command=self.open_settings) - self.settings_btn.grid(row=5, column=0, padx=10, pady=20) - - # --- Main Chat Area --- - self.main_frame = ctk.CTkFrame(self, corner_radius=0) - self.main_frame.grid(row=0, column=1, sticky="nsew") - self.main_frame.grid_rowconfigure(1, weight=1) - self.main_frame.grid_columnconfigure(0, weight=1) - - # Chat Header - self.chat_header_var = ctk.StringVar(value="Select a contact") - self.chat_header = ctk.CTkLabel(self.main_frame, textvariable=self.chat_header_var, font=ctk.CTkFont(size=18, weight="bold")) - self.chat_header.grid(row=0, column=0, padx=20, pady=10, sticky="w") - - # Secured Status Label Indicator - self.header_secure_status = ctk.CTkLabel(self.main_frame, text="Current Mode: Normal", text_color="yellow") - self.header_secure_status.grid(row=0, column=0, padx=20, pady=10, sticky="e") - - # Messages Display - self.msg_display = ctk.CTkTextbox(self.main_frame, state="disabled", wrap="word") - self.msg_display.grid(row=1, column=0, padx=20, pady=(0, 10), sticky="nsew") - self.msg_display.tag_config('sent_normal', foreground='lightgray', justify='right') - self.msg_display.tag_config('sent_secure', foreground='lightgreen', justify='right') - self.msg_display.tag_config('recv_normal', foreground='white', justify='left') - self.msg_display.tag_config('recv_secure', foreground='mediumspringgreen', justify='left') - self.msg_display.tag_config('system', foreground='red', justify='center') - - # Bottom Input Area - self.input_frame = ctk.CTkFrame(self.main_frame, height=50) - self.input_frame.grid(row=2, column=0, padx=20, pady=10, sticky="nsew") - self.input_frame.grid_columnconfigure(0, weight=1) - - self.msg_entry = ctk.CTkEntry(self.input_frame, placeholder_text="Type a message...") - self.msg_entry.grid(row=0, column=0, padx=(10, 5), pady=10, sticky="nsew") - self.msg_entry.bind("", lambda e: self.send_message()) - - # Toggle Secure SMS - self.secure_mode_var = ctk.StringVar(value="off") - self.secure_switch = ctk.CTkSwitch( - self.input_frame, - text="Secure", - command=self.toggle_mode, - variable=self.secure_mode_var, - onvalue="on", - offvalue="off" - ) - self.secure_switch.grid(row=0, column=1, padx=5, pady=10) - - self.send_btn = ctk.CTkButton(self.input_frame, text="Send", width=80, command=self.send_message) - self.send_btn.grid(row=0, column=2, padx=(5, 10), pady=10) - - def toggle_mode(self): - mode = "Secure" if self.secure_mode_var.get() == "on" else "Normal" - color = "mediumspringgreen" if mode == "Secure" else "yellow" - self.header_secure_status.configure(text=f"Current Mode: {mode}", text_color=color) - - def load_contacts(self): - # Clear sidebar list - for widget in self.contacts_scrollable_frame.winfo_children(): - widget.destroy() - - contacts = self.controller.get_contacts() - for idx, contact in enumerate(contacts): - # contact = (name, phone, pubkey) - name, phone, _ = contact - btn = ctk.CTkButton( - self.contacts_scrollable_frame, - text=f"{name}\n{phone}", - command=lambda p=phone: self.select_contact(p), - fg_color="transparent", - border_width=2, - text_color=("gray10", "#DCE4EE") - ) - btn.grid(row=idx, column=0, padx=5, pady=5, sticky="ew") - - def select_contact(self, phone): - self.current_contact = self.controller.get_contact_info(phone) - name = self.current_contact[0] - self.chat_header_var.set(f"Chat with {name} ({phone})") - - # Determine if we can do secure - has_key = self.current_contact[2] is not None - if not has_key: - self.secure_switch.deselect() - self.secure_switch.configure(state="disabled") - self.toggle_mode() - self.append_system_msg("This contact has no public key. Secure mode disabled.") - else: - self.secure_switch.configure(state="normal") - - self.refresh_messages() - - def refresh_messages(self): - self.msg_display.configure(state="normal") - self.msg_display.delete("1.0", "end") - - if self.current_contact: - messages = self.controller.get_messages(self.current_contact[1]) - for msg in messages: - # msg = (id, phone, text, date, is_secure, status) - text = msg[2] - date = msg[3] - is_secure = msg[4] - status = msg[5] # sent / recv - - tag = f"{status}_{'secure' if is_secure else 'normal'}" - prefix = "🔒 " if is_secure else "" - - display_text = f"[{date}]\n{prefix}{text}\n\n" - self.msg_display.insert("end", display_text, tag) - - self.msg_display.configure(state="disabled") - self.msg_display.yview("end") - - def append_system_msg(self, text): - self.msg_display.configure(state="normal") - self.msg_display.insert("end", f"--- {text} ---\n\n", "system") - self.msg_display.configure(state="disabled") - self.msg_display.yview("end") - - def send_message(self): - if not self.current_contact: - return - - text = self.msg_entry.get().strip() - if not text: - return - - is_secure = self.secure_mode_var.get() == "on" - phone = self.current_contact[1] - - # Ask controller to send - self.msg_entry.delete(0, 'end') - self.append_system_msg("Sending...") - self.update() # force UI refresh safely - - success = self.controller.send_message(phone, text, is_secure) - if success: - self.refresh_messages() - else: - self.append_system_msg("Failed to send SMS!") - - def open_settings(self): - SettingsWindow(self, self.controller) - -class SettingsWindow(ctk.CTkToplevel): - def __init__(self, master, controller): - super().__init__(master) - self.controller = controller - self.title("Settings") - self.geometry("500x500") - - # Bring to front - self.attributes('-topmost', 1) - - self.grid_columnconfigure(0, weight=1) - - # My Public Key - lbl1 = ctk.CTkLabel(self, text="My Public Key (Share this to chat securely):") - lbl1.grid(row=0, column=0, padx=20, pady=(20, 5), sticky="w") - - self.my_key_box = ctk.CTkTextbox(self, height=100) - self.my_key_box.grid(row=1, column=0, padx=20, pady=5, sticky="ew") - self.my_key_box.insert("1.0", self.controller.get_my_public_key()) - self.my_key_box.configure(state="disabled") - - # Add / Update Contact - lbl_add = ctk.CTkLabel(self, text="Add/Update Contact:", font=ctk.CTkFont(weight="bold")) - lbl_add.grid(row=2, column=0, padx=20, pady=(30, 5), sticky="w") - - self.name_entry = ctk.CTkEntry(self, placeholder_text="Contact Name") - self.name_entry.grid(row=3, column=0, padx=20, pady=5, sticky="ew") - - self.phone_entry = ctk.CTkEntry(self, placeholder_text="Phone Number (+989...)") - self.phone_entry.grid(row=4, column=0, padx=20, pady=5, sticky="ew") - - lbl2 = ctk.CTkLabel(self, text="Contact's Public Key (Optional but required for secure SMS):") - lbl2.grid(row=5, column=0, padx=20, pady=5, sticky="w") - - self.contact_key_box = ctk.CTkTextbox(self, height=100) - self.contact_key_box.grid(row=6, column=0, padx=20, pady=5, sticky="ew") - - self.save_btn = ctk.CTkButton(self, text="Save Contact", command=self.save_contact) - self.save_btn.grid(row=7, column=0, padx=20, pady=20) - - def save_contact(self): - name = self.name_entry.get().strip() - phone = self.phone_entry.get().strip() - pubkey = self.contact_key_box.get("1.0", 'end-1c').strip() - - if not name or not phone: - return - - if not pubkey: - pubkey = None - - self.controller.save_contact(name, phone, pubkey) - self.master.load_contacts() - self.destroy() - -if __name__ == "__main__": - # Mock Controller for standalone GUI test - class MockController: - def get_contacts(self): return [("Alice", "+1234", "key"), ("Bob", "+5678", None)] - def get_contact_info(self, phone): return ("Alice", phone, "key") if phone == "+1234" else ("Bob", phone, None) - def get_messages(self, phone): return [(1, phone, "Hi", "2023-01-01", 0, "recv"), (2, phone, "Secret", "2023-01-01", 1, "sent")] - def get_my_public_key(self): return "MY_PEM_MOCK_DATA" - def send_message(self, *args): return True - def save_contact(self, *args): pass - - app = AppGUI(MockController()) - app.mainloop() diff --git a/Crypto_Engine.py b/Crypto_Engine.py deleted file mode 100644 index e9a507c..0000000 --- a/Crypto_Engine.py +++ /dev/null @@ -1,162 +0,0 @@ -import os -import base64 -from cryptography.hazmat.primitives.asymmetric import x25519 -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.kdf.hkdf import HKDF -from cryptography.hazmat.primitives import hashes -from cryptography.hazmat.primitives.ciphers.aead import AESGCM - -# Constants -PRIVATE_KEY_FILE = "private_key.pem" -PUBLIC_KEY_FILE = "public_key.pem" - -class CryptoEngine: - def __init__(self): - self.private_key = None - self.public_key = None - self.load_or_generate_keys() - - def load_or_generate_keys(self): - """Loads keys from disk or generates a new pair if they don't exist.""" - if os.path.exists(PRIVATE_KEY_FILE) and os.path.exists(PUBLIC_KEY_FILE): - with open(PRIVATE_KEY_FILE, "rb") as f: - self.private_key = serialization.load_pem_private_key( - f.read(), password=None - ) - with open(PUBLIC_KEY_FILE, "rb") as f: - self.public_key = serialization.load_pem_public_key(f.read()) - else: - self.generate_keypair() - - def generate_keypair(self): - """Generates a new X25519 keypair and saves it to disk.""" - self.private_key = x25519.X25519PrivateKey.generate() - self.public_key = self.private_key.public_key() - - # Save private key - with open(PRIVATE_KEY_FILE, "wb") as f: - f.write(self.private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption() - )) - - # Save public key - with open(PUBLIC_KEY_FILE, "wb") as f: - f.write(self.public_key.public_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.SubjectPublicKeyInfo - )) - - def get_my_public_key_pem(self): - """Returns my public key as string.""" - return self.public_key.public_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.SubjectPublicKeyInfo - ).decode('utf-8') - - def encrypt_message(self, message: str, peer_public_key_pem: str) -> str: - """ - Encrypts a message using X25519 exchange + HKDF + AESGCM. - Returns a base64 encoded string containing ephemeral public key, IV, and ciphertext. - """ - if not peer_public_key_pem: - raise ValueError("Peer public key is empty.") - - peer_public_key = serialization.load_pem_public_key(peer_public_key_pem.encode('utf-8')) - - # Generate an ephemeral keypair for this message to provide forward secrecy - ephemeral_private_key = x25519.X25519PrivateKey.generate() - ephemeral_public_key = ephemeral_private_key.public_key() - ephemeral_pub_bytes = ephemeral_public_key.public_bytes( - encoding=serialization.Encoding.Raw, - format=serialization.PublicFormat.Raw - ) - - # Perform key exchange - shared_key = ephemeral_private_key.exchange(peer_public_key) - - # Derive a symmetric key using HKDF - derived_key = HKDF( - algorithm=hashes.SHA256(), - length=32, - salt=None, - info=b'sms-secure-encryption' - ).derive(shared_key) - - # Encrypt with AES-GCM - aesgcm = AESGCM(derived_key) - nonce = os.urandom(12) - ciphertext = aesgcm.encrypt(nonce, message.encode('utf-8'), None) - - # Construct final payload: [Ephemeral Pub Key (32)] + [Nonce (12)] + [Ciphertext + Tag] - payload = ephemeral_pub_bytes + nonce + ciphertext - - # We prepend a marker to easily identify secure messages - marker = "SEC:" - b64_payload = base64.b64encode(payload).decode('ascii') - - return marker + b64_payload - - def decrypt_message(self, secure_payload: str) -> str: - """ - Decrypts a secure payload. - Assumes it starts with 'SEC:'. - """ - if not secure_payload.startswith("SEC:"): - raise ValueError("Not a secure message format.") - - b64_payload = secure_payload[4:] - try: - payload = base64.b64decode(b64_payload) - except Exception: - raise ValueError("Invalid Base64 payload.") - - if len(payload) < 32 + 12 + 16: # Length of pub key + nonce + tag - raise ValueError("Payload too short.") - - ephemeral_pub_bytes = payload[:32] - nonce = payload[32:44] - ciphertext = payload[44:] - - ephemeral_public_key = x25519.X25519PublicKey.from_public_bytes(ephemeral_pub_bytes) - - # Key exchange - shared_key = self.private_key.exchange(ephemeral_public_key) - - # Derive symmetric key - derived_key = HKDF( - algorithm=hashes.SHA256(), - length=32, - salt=None, - info=b'sms-secure-encryption' - ).derive(shared_key) - - # Decrypt - aesgcm = AESGCM(derived_key) - try: - plaintext = aesgcm.decrypt(nonce, ciphertext, None) - return plaintext.decode('utf-8') - except Exception as e: - raise ValueError("Decryption failed. Invalid key or message tampered.") from e - -if __name__ == "__main__": - # Test - alice = CryptoEngine() - alice_pub = alice.get_my_public_key_pem() - - bob = CryptoEngine() - bob_pub = bob.get_my_public_key_pem() - - msg = "This is a highly secret message!" - - # Alice sends to Bob - encrypted = alice.encrypt_message(msg, bob_pub) - print("Encrypted:", encrypted) - - # Bob decrypts - decrypted = bob.decrypt_message(encrypted) - print("Decrypted:", decrypted) - - assert msg == decrypted - print("Crypto Engine OK.") diff --git a/DB_Handler.py b/DB_Handler.py deleted file mode 100644 index 7f43392..0000000 --- a/DB_Handler.py +++ /dev/null @@ -1,134 +0,0 @@ -import sqlite3 -from datetime import datetime -import os - -DB_FILE = "sms_app.db" - -class DBHandler: - def __init__(self, db_path=DB_FILE): - self.db_path = db_path - self._initialize_db() - - def _get_connection(self): - return sqlite3.connect(self.db_path) - - def _initialize_db(self): - with self._get_connection() as conn: - cursor = conn.cursor() - # Create contacts table - cursor.execute(''' - CREATE TABLE IF NOT EXISTS contacts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - phone TEXT UNIQUE NOT NULL, - public_key TEXT - ) - ''') - # Create messages table - cursor.execute(''' - CREATE TABLE IF NOT EXISTS messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - phone TEXT NOT NULL, - text TEXT NOT NULL, - date TEXT NOT NULL, - is_secure INTEGER NOT NULL, - status TEXT NOT NULL, - FOREIGN KEY(phone) REFERENCES contacts(phone) - ) - ''') - # Create settings table for our own keypair if needed (though usually we save keys to files) - cursor.execute(''' - CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT - ) - ''') - conn.commit() - - # --- Contacts Methods --- - def add_contact(self, name, phone, public_key=None): - with self._get_connection() as conn: - cursor = conn.cursor() - try: - cursor.execute('INSERT INTO contacts (name, phone, public_key) VALUES (?, ?, ?)', - (name, phone, public_key)) - conn.commit() - return True - except sqlite3.IntegrityError: - return False # Phone number already exists - - def update_contact_key(self, phone, public_key): - with self._get_connection() as conn: - cursor = conn.cursor() - cursor.execute('UPDATE contacts SET public_key = ? WHERE phone = ?', (public_key, phone)) - conn.commit() - - def get_contact(self, phone): - with self._get_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT name, phone, public_key FROM contacts WHERE phone = ?', (phone,)) - return cursor.fetchone() - - def get_all_contacts(self): - with self._get_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT name, phone, public_key FROM contacts') - return cursor.fetchall() - - def set_contact_name_if_not_exists(self, phone, name): - with self._get_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT phone FROM contacts WHERE phone = ?', (phone,)) - if not cursor.fetchone(): - cursor.execute('INSERT INTO contacts (name, phone, public_key) VALUES (?, ?, ?)', (name, phone, None)) - conn.commit() - - # --- Messages Methods --- - def add_message(self, phone, text, is_secure, status): - date_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - with self._get_connection() as conn: - cursor = conn.cursor() - cursor.execute(''' - INSERT INTO messages (phone, text, date, is_secure, status) - VALUES (?, ?, ?, ?, ?) - ''', (phone, text, date_str, int(is_secure), status)) - conn.commit() - return cursor.lastrowid - - def get_messages_for_contact(self, phone): - with self._get_connection() as conn: - cursor = conn.cursor() - cursor.execute(''' - SELECT id, phone, text, date, is_secure, status - FROM messages - WHERE phone = ? - ORDER BY date ASC - ''', (phone,)) - return cursor.fetchall() - - def delete_message(self, msg_id): - with self._get_connection() as conn: - cursor = conn.cursor() - cursor.execute('DELETE FROM messages WHERE id = ?', (msg_id,)) - conn.commit() - - # --- Settings Methods --- - def get_setting(self, key): - with self._get_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT value FROM settings WHERE key = ?', (key,)) - row = cursor.fetchone() - return row[0] if row else None - - def set_setting(self, key, value): - with self._get_connection() as conn: - cursor = conn.cursor() - cursor.execute('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)', (key, value)) - conn.commit() - -if __name__ == "__main__": - db = DBHandler("test_sms.db") - db.add_contact("Alice", "+1234567890", "PUBLIC_KEY_CONTENT") - db.add_message("+1234567890", "Hello Alice!", is_secure=False, status="sent") - print("Database OK.") - os.remove("test_sms.db") diff --git a/GSM_Manager.py b/GSM_Manager.py deleted file mode 100644 index 5bed997..0000000 --- a/GSM_Manager.py +++ /dev/null @@ -1,218 +0,0 @@ -import serial -import time -import threading -import re - -class GSMManager: - def __init__(self, port='COM1', baudrate=115200, message_callback=None): - """ - message_callback: function(sender_number, message_text) - called when a new SMS is fully read. - """ - self.port = port - self.baudrate = baudrate - self.serial_conn = None - self.is_running = False - self.read_thread = None - self.message_callback = message_callback - - # Lock for thread-safe AT command execution - self.lock = threading.Lock() - - def connect(self): - try: - self.serial_conn = serial.Serial( - port=self.port, - baudrate=self.baudrate, - timeout=1 # 1 second timeout for readline - ) - self.is_running = True - - # Setup Modem - self.send_at_cmd('AT') - self.send_at_cmd('AT+CMGF=1') # Text Mode - self.send_at_cmd('AT+CNMI=2,1,0,0,0') # Route URC to TE for +CMTI - - # Start Read Thread - self.read_thread = threading.Thread(target=self._read_loop, daemon=True) - self.read_thread.start() - - return True - except Exception as e: - print(f"Failed to connect GSM: {e}") - return False - - def disconnect(self): - self.is_running = False - if self.serial_conn and self.serial_conn.is_open: - self.serial_conn.close() - if self.read_thread: - self.read_thread.join(timeout=2) - - def send_at_cmd(self, cmd, expected_resp='OK', timeout=2): - """Send an AT command safely and wait for a response.""" - with self.lock: - if not self.serial_conn or not self.serial_conn.is_open: - return False, "Not connected" - - # Flush input buffer to clear old data - self.serial_conn.reset_input_buffer() - - full_cmd = cmd + '\r\n' - self.serial_conn.write(full_cmd.encode('ascii')) - - start_time = time.time() - response_lines = [] - - while time.time() - start_time < timeout: - if self.serial_conn.in_waiting: - line = self.serial_conn.readline().decode('ascii', errors='ignore').strip() - if line: - response_lines.append(line) - if expected_resp in line or 'ERROR' in line: - break - else: - time.sleep(0.05) - - return expected_resp in "\n".join(response_lines), response_lines - - def send_sms(self, phone_number, message): - """ - Sends an SMS. For long messages, splits them up. - (Note: Quectel M66 supports up to 160 chars in 7-bit text mode natively. - For simplicity, we chunk it to 150 chars max per SMS if it exceeds). - """ - chunk_size = 140 # Safe limit for standard ASCII/Base64 - chunks = [message[i:i+chunk_size] for i in range(0, len(message), chunk_size)] - - for idx, chunk in enumerate(chunks): - # If multiple chunks, we can prepend (1/2) kind of indicator for normal texts - # But for base64 secure chunks, they might need stitching. - # For simplicity, we just send them sequentially. - # In a production resilient app, multipart PDU mode is preferred. - - success = self._send_single_sms(phone_number, chunk) - if not success: - return False - - if len(chunks) > 1: - time.sleep(2) # Add some delay between multi-part sends - - return True - - def _send_single_sms(self, phone_number, text): - with self.lock: - try: - self.serial_conn.reset_input_buffer() - - # Start SMS prompt - self.serial_conn.write(f'AT+CMGS="{phone_number}"\r\n'.encode('ascii')) - - # Wait for '>' - start_time = time.time() - prompt_ready = False - while time.time() - start_time < 2: - if self.serial_conn.in_waiting: - char = self.serial_conn.read().decode('ascii', errors='ignore') - if char == '>': - prompt_ready = True - break - time.sleep(0.05) - - if not prompt_ready: - print("SMS prompt '>' not received.") - # Send ESC just in case - self.serial_conn.write(chr(27).encode('ascii')) - return False - - # Write text and send Ctrl+Z (ASCII 26) - self.serial_conn.write(text.encode('ascii') + chr(26).encode('ascii')) - - # Wait for +CMGS and OK - start_time = time.time() - success = False - response_lines = [] - while time.time() - start_time < 10: # SMS sending can take time - if self.serial_conn.in_waiting: - line = self.serial_conn.readline().decode('ascii', errors='ignore').strip() - if line: - response_lines.append(line) - if 'OK' in line: - success = True - break - elif 'ERROR' in line: - break - else: - time.sleep(0.1) - - return success - except Exception as e: - print(f"Error sending SMS: {e}") - return False - - def _read_loop(self): - """Background thread continuously reading URCs like +CMTI""" - while self.is_running: - try: - if self.serial_conn and self.serial_conn.in_waiting: - # We only read if we can acquire the lock without waiting, - # otherwise it means AT command is in progress and it handles reading. - # Wait, AT command reading in `send_at_cmd` reads synchronously. URCs might mix. - # Standard practice for URCs: the AT command sender ignores unexpected URCs, - # OR we have a dedicated thread that reads everything and routes URCs vs command responses. - # For simplicity, we try to grab the lock. If locked, AT comm is happening. - if self.lock.acquire(blocking=False): - try: - line = self.serial_conn.readline().decode('ascii', errors='ignore').strip() - if line.startswith('+CMTI:'): - # Incoming message: +CMTI: "SM",1 - match = re.search(r'\+CMTI:\s*".*?",(\d+)', line) - if match: - msg_index = int(match.group(1)) - # Process it in a new thread or queue to avoid blocking this loop - threading.Thread(target=self.process_incoming_sms, args=(msg_index,), daemon=True).start() - finally: - self.lock.release() - except Exception as e: - pass - time.sleep(0.1) - - def process_incoming_sms(self, index): - """Reads the SMS payload from memory and deletes it.""" - # read_sms uses AT command, so it uses the lock - # wait a bit ensuring any ongoing AT comm finishes - time.sleep(1) - - success, response = self.send_at_cmd(f'AT+CMGR={index}', expected_resp='OK', timeout=3) - if success: - sender = "Unknown" - text_lines = [] - is_text_block = False - - for line in response: - if line.startswith('+CMGR:'): - # +CMGR: "REC UNREAD","+989123456789",,"23/05/26,10:30:00+14" - parts = line.split(',') - if len(parts) >= 2: - sender = parts[1].strip('"') - is_text_block = True - elif is_text_block and line not in ['OK', 'ERROR'] and not line.startswith('+CMGR:'): - text_lines.append(line) - - full_text = "\n".join(text_lines) - - if self.message_callback: - self.message_callback(sender, full_text) - - # Delete message to free space - self.send_at_cmd(f'AT+CMGD={index}') - -if __name__ == "__main__": - def on_sms(sender, text): - print(f"\n[NEW SMS] From: {sender}\nText:{text}\n") - - gsm = GSMManager(port='COM3') - print("Testing locally...") - # This will obviously fail without actual M66 module on COM3, but acts as boilerplate check. - # gsm.connect() - # gsm.disconnect() diff --git a/main.py b/main.py index 8ca2075..a40a617 100644 --- a/main.py +++ b/main.py @@ -258,7 +258,7 @@ def main(): return 1 try: - from secure_sms.controller import AppController + from secure_sms.application.controller import AppController from secure_sms.ui import SecureSmsApp controller = AppController() diff --git a/refactor.py b/refactor.py new file mode 100644 index 0000000..c1a4c14 --- /dev/null +++ b/refactor.py @@ -0,0 +1,70 @@ +import os +import shutil +from pathlib import Path + +def main(): + base = Path(r"c:\Users\Pars\Desktop\saba-python\secure_sms") + + print("[1/3] Creating Architectural Directories...") + for folder in ['core', 'application', 'infrastructure', 'ui']: + (base / folder).mkdir(exist_ok=True) + (base / folder / '__init__.py').touch(exist_ok=True) + + print("[2/3] Moving Domain Logic Files...") + moves = { + 'models.py': 'core', + 'protocol.py': 'core', + 'security.py': 'core', + 'services.py': 'application', + 'controller.py': 'application', + 'database.py': 'infrastructure', + 'gsm.py': 'infrastructure' + } + + for file_name, folder in moves.items(): + src = base / file_name + dst = base / folder / file_name + if src.exists(): + shutil.move(str(src), str(dst)) + print(f' -> Moved {file_name} to {folder}/') + + print("[3/3] Refactoring Import Statements...") + replacements = { + 'from secure_sms.infrastructure.database': 'from secure_sms.infrastructure.database', + 'from secure_sms.infrastructure.gsm': 'from secure_sms.infrastructure.gsm', + 'from secure_sms.application.services': 'from secure_sms.application.services', + 'from secure_sms.application.controller': 'from secure_sms.application.controller', + 'from secure_sms.core.models': 'from secure_sms.core.models', + 'from secure_sms.core.protocol': 'from secure_sms.core.protocol', + 'from secure_sms.core.security': 'from secure_sms.core.security', + 'import secure_sms.infrastructure.database': 'import secure_sms.infrastructure.database' + } + + project_root = Path(r"c:\Users\Pars\Desktop\saba-python") + for py_file in project_root.rglob('*.py'): + if py_file.name == '.venv' or '.runtime-venv' in str(py_file): + continue + try: + content = py_file.read_text(encoding='utf-8') + orig = content + for old, new in replacements.items(): + content = content.replace(old, new) + if content != orig: + py_file.write_text(content, encoding='utf-8') + print(f' -> Updated imports in {py_file.name}') + except Exception as e: + pass + + # Cleanup obsolete root files + print("[Cleanup] Removing Legacy Files...") + legacy = ["App_GUI.py", "Crypto_Engine.py", "DB_Handler.py", "GSM_Manager.py"] + for legacy_file in legacy: + f = project_root / legacy_file + if f.exists(): + f.unlink() + print(f' -> Deleted {legacy_file}') + + print("\n✅ Migration complete! Backend is now strictly adhering to Clean Architecture.") + +if __name__ == "__main__": + main() diff --git a/secure_sms/__pycache__/ui.cpython-313.pyc b/secure_sms/__pycache__/ui.cpython-313.pyc index ce9c9b9a60ded6d31d45dda933a907dd707812d0..1ba0a109f41b448b7fd15f19464892d265c9d484 100644 GIT binary patch delta 26663 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 z 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 literal 0 HcmV?d00001 diff --git a/secure_sms/controller.py b/secure_sms/application/controller.py similarity index 66% rename from secure_sms/controller.py rename to secure_sms/application/controller.py index 308611d..8af4180 100644 --- a/secure_sms/controller.py +++ b/secure_sms/application/controller.py @@ -1,18 +1,48 @@ from __future__ import annotations +import queue +import threading from typing import Optional -from secure_sms.database import Database -from secure_sms.gsm import GSMGateway -from secure_sms.services import SecureMessagingService +from secure_sms.infrastructure.database import Database +from secure_sms.infrastructure.gsm import IMessageGateway, GSMGateway +from secure_sms.application.services import SecureMessagingService class AppController: def __init__(self): self.db = Database() self.service = SecureMessagingService(self.db) - self.gsm: Optional[GSMGateway] = None + self.gsm: Optional[IMessageGateway] = None self.ui = None + self._outbox = queue.Queue() + self._worker_thread = threading.Thread(target=self._outbox_worker, daemon=True) + self._worker_thread.start() + + def _outbox_worker(self): + while True: + try: + task = self._outbox.get() + if task is None: + break + phone = task.get("phone") + frames = task.get("frames") + msg_id = task.get("msg_id") + + if self.gsm and self.gsm.is_connected: + sent = self.gsm.send_frames(phone, frames) + state = "sent" if sent else "failed" + else: + # In simulation mode (no modem attached), we instantly mark it simul-sent. + sent = True + state = "simulated" + + if msg_id: + self.db.update_message_transport_state(msg_id, state) + self._notify_ui(phone) + self._outbox.task_done() + except Exception: + pass def bind_ui(self, ui): self.ui = ui @@ -74,40 +104,22 @@ class AppController: except Exception as exc: return False, str(exc) - if self.gsm and self.gsm.is_connected: - sent = self.gsm.send_frames(phone, frames) - state = "sent" if sent else "failed" - else: - sent = True - state = "simulated" - - if sent: - self.service.store_outgoing_message(phone, text, mode, state) - self._notify_ui(phone) - return True, state - return False, "failed" + msg_id = self.service.store_outgoing_message(phone, text, mode, "queued") + self._outbox.put({"phone": phone, "frames": frames, "msg_id": msg_id}) + self._notify_ui(phone) + return True, "queued" def request_secure(self, phone: str) -> tuple[bool, str]: frames = self.service.request_secure_channel(phone) - if self.gsm and self.gsm.is_connected: - ok = self.gsm.send_frames(phone, frames) - status = "sent" if ok else "failed" - else: - ok = True - status = "simulated" + self._outbox.put({"phone": phone, "frames": frames, "msg_id": None}) self._notify_ui(phone) - return ok, status + return True, "queued" def switch_to_normal(self, phone: str) -> tuple[bool, str]: frames = self.service.request_normal_mode(phone) - if self.gsm and self.gsm.is_connected: - ok = self.gsm.send_frames(phone, frames) - status = "sent" if ok else "failed" - else: - ok = True - status = "simulated" + self._outbox.put({"phone": phone, "frames": frames, "msg_id": None}) self._notify_ui(phone) - return ok, status + return True, "queued" def get_admin_snapshot(self) -> dict: snapshot = self.service.get_admin_snapshot() diff --git a/secure_sms/services.py b/secure_sms/application/services.py similarity index 97% rename from secure_sms/services.py rename to secure_sms/application/services.py index 7abc8a0..738e655 100644 --- a/secure_sms/services.py +++ b/secure_sms/application/services.py @@ -2,9 +2,9 @@ import platform from pathlib import Path from typing import Optional -from secure_sms.database import Database, utc_now -from secure_sms.models import ContactDetails, ContactSummary, MessageView, PendingPacketView, SecureEventView -from secure_sms.protocol import ( +from secure_sms.infrastructure.database import Database, utc_now +from secure_sms.core.models import ContactDetails, ContactSummary, MessageView, PendingPacketView, SecureEventView +from secure_sms.core.protocol import ( build_control_frames, build_message_frames, decode_control_payload, @@ -12,7 +12,7 @@ from secure_sms.protocol import ( encode_plain_body, parse_frame, ) -from secure_sms.security import ECCCryptoService, PasswordManager, StorageCipher +from secure_sms.core.security import ECCCryptoService, PasswordManager, StorageCipher SYSTEM_CONTACT_LABEL = "مخاطب ناشناس" @@ -183,8 +183,8 @@ class SecureMessagingService: encoded_payload = encode_plain_body(text) return build_message_frames("N", encoded_payload), "normal" - def store_outgoing_message(self, phone: str, text: str, mode: str, transport_state: str): - self.db.add_message( + def store_outgoing_message(self, phone: str, text: str, mode: str, transport_state: str) -> int: + return self.db.add_message( phone=phone, direction="out", body_enc=self._enc(text), diff --git a/secure_sms/core/__init__.py b/secure_sms/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/secure_sms/models.py b/secure_sms/core/models.py similarity index 100% rename from secure_sms/models.py rename to secure_sms/core/models.py diff --git a/secure_sms/protocol.py b/secure_sms/core/protocol.py similarity index 97% rename from secure_sms/protocol.py rename to secure_sms/core/protocol.py index 1d37180..b401e5e 100644 --- a/secure_sms/protocol.py +++ b/secure_sms/core/protocol.py @@ -3,7 +3,7 @@ import uuid from dataclasses import dataclass from typing import Optional -from secure_sms.security import b64u_decode, b64u_encode +from secure_sms.core.security import b64u_decode, b64u_encode FRAME_PREFIX = "@SSM1" diff --git a/secure_sms/security.py b/secure_sms/core/security.py similarity index 100% rename from secure_sms/security.py rename to secure_sms/core/security.py diff --git a/secure_sms/infrastructure/__init__.py b/secure_sms/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/secure_sms/database.py b/secure_sms/infrastructure/database.py similarity index 97% rename from secure_sms/database.py rename to secure_sms/infrastructure/database.py index 468807a..25feb8f 100644 --- a/secure_sms/database.py +++ b/secure_sms/infrastructure/database.py @@ -3,7 +3,7 @@ from datetime import datetime from pathlib import Path from typing import Optional -from secure_sms.security import SecurityMetadata, StorageCipher +from secure_sms.core.security import SecurityMetadata, StorageCipher DB_FILE = "secure_sms_v2.db" @@ -279,6 +279,14 @@ class Database: conn.commit() return int(cursor.lastrowid) + def update_message_transport_state(self, message_id: int, transport_state: str): + with self._connect() as conn: + conn.execute( + "UPDATE messages SET transport_state = ? WHERE id = ?", + (transport_state, message_id), + ) + conn.commit() + def list_message_rows(self, phone: str) -> list[sqlite3.Row]: with self._connect() as conn: cursor = conn.cursor() diff --git a/secure_sms/gsm.py b/secure_sms/infrastructure/gsm.py similarity index 93% rename from secure_sms/gsm.py rename to secure_sms/infrastructure/gsm.py index 00045f9..f821f19 100644 --- a/secure_sms/gsm.py +++ b/secure_sms/infrastructure/gsm.py @@ -1,3 +1,4 @@ +import abc import re import threading import time @@ -6,7 +7,22 @@ from typing import Callable, Optional import serial -class GSMGateway: +class IMessageGateway(abc.ABC): + @property + @abc.abstractmethod + def is_connected(self) -> bool: pass + + @abc.abstractmethod + def connect(self) -> bool: pass + + @abc.abstractmethod + def disconnect(self) -> None: pass + + @abc.abstractmethod + def send_frames(self, phone: str, frames: list[str]) -> bool: pass + + +class GSMGateway(IMessageGateway): def __init__( self, port: str, diff --git a/secure_sms/ui.py b/secure_sms/ui.py index 52a016d..e264b2a 100644 --- a/secure_sms/ui.py +++ b/secure_sms/ui.py @@ -12,30 +12,32 @@ except ImportError: get_display = None -ctk.set_appearance_mode("light") +ctk.set_appearance_mode("dark") ctk.set_default_color_theme("blue") -PRIMARY = "#175B4B" -PRIMARY_DARK = "#0E4236" -PRIMARY_SOFT = "#DFF1E8" -ACCENT = "#E8A04D" -ACCENT_DARK = "#C97E2D" -BACKGROUND = "#F5EFE7" -CARD = "#FFFDFC" -SURFACE = "#FBF7F2" -INPUT_BG = "#FFFCF8" -TEXT = "#16312A" -MUTED = "#6B7A77" -DANGER = "#B6465F" -WARNING = "#9A6C3C" -BORDER = "#E5DCCE" -KEYBOARD_BG = "#D4DCE2" -KEY_FACE = "#FFFFFF" -KEY_MUTED = "#BCC1C9" -KEY_TEXT = "#000000" -SIDEBAR = "#1B5A4A" -SIDEBAR_SOFT = "#245E4E" +PRIMARY = "#2AABEE" +PRIMARY_DARK = "#229ED9" +PRIMARY_SOFT = "#1C3A4F" +ACCENT = "#2AABEE" +ACCENT_DARK = "#229ED9" +BACKGROUND = "#0E1621" +CARD = "#17212B" +SURFACE = "#17212B" +INPUT_BG = "#242F3D" +TEXT = "#FFFFFF" +MUTED = "#6C7883" +DANGER = "#E05D57" +WARNING = "#E0A356" +BORDER = "#232E3C" +KEYBOARD_BG = "#17212B" +KEY_FACE = "#242F3D" +KEY_MUTED = "#1C2733" +KEY_TEXT = "#FFFFFF" +SIDEBAR = "#17212B" +SIDEBAR_SOFT = "#242F3D" +BUBBLE_OUT = "#2B5278" +BUBBLE_IN = "#182533" FONT_BODY = "Tahoma" if os.name == "nt" else "DejaVu Sans" FONT_TITLE = "Tahoma" if os.name == "nt" else "DejaVu Sans" RTL_PATTERN = re.compile(r"[\u0600-\u06FF]") @@ -560,7 +562,7 @@ class SecureSmsApp(ctk.CTk): def _show_lock_screen(self): self._clear_root() - frame = ctk.CTkFrame(self.root_frame, fg_color=SURFACE, corner_radius=28, border_width=1, border_color=BORDER) + frame = ctk.CTkFrame(self.root_frame, fg_color=CARD, corner_radius=16, border_width=1, border_color=BORDER) frame.place( relx=0.5, rely=0.5, @@ -574,55 +576,69 @@ class SecureSmsApp(ctk.CTk): if not self.controller.is_bootstrapped() else 'برای ورود، رمز اصلی برنامه را وارد کن.' ) + RTLLabel( + frame, + text='📨 صبا', + font=ctk.CTkFont(family=FONT_TITLE, size=32, weight="bold"), + text_color=PRIMARY, + ).pack(pady=(24, 4)) RTLLabel( frame, text=title, - font=ctk.CTkFont(family=FONT_BODY, size=28, weight="bold"), + font=ctk.CTkFont(family=FONT_BODY, size=20, weight="bold"), text_color=TEXT, - ).pack(pady=(30, 10)) + ).pack(pady=(4, 6)) RTLLabel( frame, text=subtitle, wraplength=min(self.window_width - 110, 420), justify="right", - font=ctk.CTkFont(family=FONT_BODY, size=16), + font=ctk.CTkFont(family=FONT_BODY, size=14), text_color=MUTED, ).pack(padx=28) self.password_entry = RTLEntry( frame, placeholder_text='رمز اصلی', show="*", - height=48, - font=ctk.CTkFont(family=FONT_BODY, size=17), + height=44, + font=ctk.CTkFont(family=FONT_BODY, size=16), + fg_color=INPUT_BG, + border_color=BORDER, + text_color=TEXT, ) - self.password_entry.pack(fill="x", padx=42, pady=(24, 12)) + self.password_entry.pack(fill="x", padx=36, pady=(20, 10)) self.confirm_entry = None if not self.controller.is_bootstrapped(): self.confirm_entry = RTLEntry( frame, placeholder_text='تکرار رمز', show="*", - height=48, - font=ctk.CTkFont(family=FONT_BODY, size=17), + height=44, + font=ctk.CTkFont(family=FONT_BODY, size=16), + fg_color=INPUT_BG, + border_color=BORDER, + text_color=TEXT, ) - self.confirm_entry.pack(fill="x", padx=42, pady=8) + self.confirm_entry.pack(fill="x", padx=36, pady=6) self.lock_message = RTLLabel( frame, text="", text_color=DANGER, - font=ctk.CTkFont(family=FONT_BODY, size=14 if self.is_portrait else 15), + font=ctk.CTkFont(family=FONT_BODY, size=13), ) - self.lock_message.pack(pady=(6, 10)) + self.lock_message.pack(pady=(4, 8)) action_text = 'شروع برنامه' if not self.controller.is_bootstrapped() else 'ورود' RTLButton( frame, text=action_text, - height=48, + height=44, + corner_radius=8, fg_color=PRIMARY, hover_color=PRIMARY_DARK, + text_color="#FFFFFF", command=self._submit_lock_screen, - font=ctk.CTkFont(family=FONT_BODY, size=18, weight="bold"), - ).pack(fill="x", padx=42, pady=(8, 12)) + font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"), + ).pack(fill="x", padx=36, pady=(4, 16)) self.password_entry.bind("", lambda _event: self._submit_lock_screen()) self._register_text_input(self.password_entry, title='رمز اصلی', layout="en", submit=self._submit_lock_screen) if self.confirm_entry: @@ -713,66 +729,71 @@ class SecureSmsApp(ctk.CTk): main_row = 1 if self.is_portrait else 0 main_column = 0 if self.is_portrait else 1 - self.sidebar = ctk.CTkFrame(self.root_frame, fg_color=SIDEBAR, corner_radius=0) + self.sidebar = ctk.CTkFrame(self.root_frame, fg_color=SIDEBAR, corner_radius=0, border_width=0) self.sidebar.grid(row=0, column=0, sticky="nsew") self.sidebar.grid_columnconfigure(0, weight=1) self.sidebar.grid_rowconfigure(5, weight=1) + sidebar_header = ctk.CTkFrame(self.sidebar, fg_color="transparent") + sidebar_header.grid(row=0, column=0, padx=14, pady=(12, 2), sticky="ew") + sidebar_header.grid_columnconfigure(0, weight=1) RTLLabel( - self.sidebar, + sidebar_header, text='صبا', - text_color="white", + text_color=TEXT, font=ctk.CTkFont(family=FONT_TITLE, size=title_size, weight="bold"), - ).grid(row=0, column=0, padx=20, pady=(16, 2), sticky="e") + ).grid(row=0, column=0, sticky="e") RTLLabel( - self.sidebar, - text='پیام\u200cرسان امن و ساده برای کاربر غیر فنی', - text_color="#D5E8E1", + sidebar_header, + text='پیام\u200cرسان امن', + text_color=MUTED, font=ctk.CTkFont(family=FONT_BODY, size=subtitle_size), - ).grid(row=1, column=0, padx=20, sticky="e") + ).grid(row=1, column=0, sticky="e") self.connection_badge = RTLLabel( self.sidebar, text="", - corner_radius=999, - fg_color="#2E7D62", - text_color="white", - font=ctk.CTkFont(family=FONT_BODY, size=15 if self.is_portrait else 14, weight="bold"), - padx=14, - pady=8, + corner_radius=6, + fg_color="#1C3A4F", + text_color=MUTED, + font=ctk.CTkFont(family=FONT_BODY, size=13, weight="bold"), + padx=10, + pady=6, ) - self.connection_badge.grid(row=2, column=0, padx=20, pady=(12, 10), sticky="e") + self.connection_badge.grid(row=2, column=0, padx=14, pady=(6, 8), sticky="ew") top_actions = ctk.CTkFrame(self.sidebar, fg_color="transparent") - top_actions.grid(row=3, column=0, padx=14, pady=(0, 8), sticky="ew") + top_actions.grid(row=3, column=0, padx=14, pady=(0, 6), sticky="ew") top_actions.grid_columnconfigure((0, 1), weight=1) RTLButton( top_actions, - text='مخاطب جدید', + text='گفتگوی جدید', command=self._open_contact_dialog, - fg_color=ACCENT, - text_color="#3A2514", - hover_color=ACCENT_DARK, + fg_color=PRIMARY, + text_color="#FFFFFF", + hover_color=PRIMARY_DARK, + corner_radius=8, height=action_height, - font=ctk.CTkFont(family=FONT_BODY, size=15 if self.is_portrait else 14, weight="bold"), - ).grid(row=0, column=0, padx=6, sticky="ew") + font=ctk.CTkFont(family=FONT_BODY, size=14, weight="bold"), + ).grid(row=0, column=0, padx=4, sticky="ew") RTLButton( top_actions, - text='تنظیمات', + text='⚙ تنظیمات', command=self._open_settings_panel, - fg_color="#F4EFE9", - text_color="#15302B", - hover_color="#ECE1D5", + fg_color=INPUT_BG, + text_color=TEXT, + hover_color="#2D3A49", + corner_radius=8, height=action_height, - font=ctk.CTkFont(family=FONT_BODY, size=15 if self.is_portrait else 14, weight="bold"), - ).grid(row=0, column=1, padx=6, sticky="ew") + font=ctk.CTkFont(family=FONT_BODY, size=14, weight="bold"), + ).grid(row=0, column=1, padx=4, sticky="ew") self.contact_form_card = ctk.CTkFrame( self.sidebar, fg_color=SIDEBAR_SOFT, - corner_radius=18, + corner_radius=10, border_width=1, - border_color="#3B7B66", + border_color=BORDER, ) self.contact_form_card.grid(row=4, column=0, padx=14, pady=(0, 10), sticky="ew") self.contact_form_card.grid_columnconfigure(0, weight=1) @@ -809,20 +830,22 @@ class SecureSmsApp(ctk.CTk): RTLButton( contact_actions, text='ذخیره', - fg_color=ACCENT, - text_color="#3A2514", - hover_color=ACCENT_DARK, + fg_color=PRIMARY, + text_color="#FFFFFF", + hover_color=PRIMARY_DARK, command=self._save_contact_inline, + corner_radius=8, font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), height=40, ).grid(row=0, column=0, padx=4, sticky="ew") RTLButton( contact_actions, text='بستن', - fg_color="#F4EFE9", + fg_color=INPUT_BG, text_color=TEXT, - hover_color="#ECE1D5", + hover_color="#2D3A49", command=self._hide_contact_form, + corner_radius=8, font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), height=40, ).grid(row=0, column=1, padx=4, sticky="ew") @@ -839,46 +862,46 @@ class SecureSmsApp(ctk.CTk): self.contacts_frame = RTLScrollableFrame( self.sidebar, height=160 if self.is_portrait else 320, - label_text='مخاطب\u200cها', - label_font=ctk.CTkFont(family=FONT_BODY, size=17 if self.is_portrait else 18, weight="bold"), + label_text='گفتگو\u200cها', + label_font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), fg_color="transparent", ) - self.contacts_frame.grid(row=5, column=0, padx=12, pady=8, sticky="nsew") + self.contacts_frame.grid(row=5, column=0, padx=8, pady=4, sticky="nsew") self.main_panel = ctk.CTkFrame(self.root_frame, fg_color=BACKGROUND, corner_radius=0) self.main_panel.grid(row=main_row, column=main_column, sticky="nsew") self.main_panel.grid_rowconfigure(1, weight=1) self.main_panel.grid_columnconfigure(0, weight=1) - self.header_card = ctk.CTkFrame(self.main_panel, fg_color=CARD, corner_radius=24, border_width=1, border_color=BORDER) - self.header_card.grid(row=0, column=0, padx=outer_pad, pady=(outer_pad, inner_pad), sticky="ew") + self.header_card = ctk.CTkFrame(self.main_panel, fg_color=CARD, corner_radius=0, border_width=0) + self.header_card.grid(row=0, column=0, sticky="ew") self.header_card.grid_columnconfigure(0, weight=1) self.header_card.grid_columnconfigure(1, weight=0) self.chat_title = RTLLabel( self.header_card, text='یک مخاطب را انتخاب کن', text_color=TEXT, - font=ctk.CTkFont(family=FONT_TITLE, size=18 if self.is_portrait else 20, weight="bold"), + font=ctk.CTkFont(family=FONT_TITLE, size=16 if self.is_portrait else 18, weight="bold"), ) - self.chat_title.grid(row=0, column=0, padx=22, pady=(18, 4), sticky="e") + self.chat_title.grid(row=0, column=0, padx=16, pady=(10, 2), sticky="e") self.chat_subtitle = RTLLabel( self.header_card, text='در اینجا فقط دو حالت داری: عادی یا امن', text_color=MUTED, - font=ctk.CTkFont(family=FONT_BODY, size=14), + font=ctk.CTkFont(family=FONT_BODY, size=12), ) - self.chat_subtitle.grid(row=1, column=0, padx=22, pady=(0, 18), sticky="e") + self.chat_subtitle.grid(row=1, column=0, padx=16, pady=(0, 10), sticky="e") self.mode_badge = RTLLabel( self.header_card, text='عادی', fg_color=PRIMARY_SOFT, text_color=PRIMARY, - corner_radius=999, - padx=20, - pady=10, - font=ctk.CTkFont(family=FONT_BODY, size=14, weight="bold"), + corner_radius=6, + padx=14, + pady=6, + font=ctk.CTkFont(family=FONT_BODY, size=12, weight="bold"), ) - self.mode_badge.grid(row=0, column=1, rowspan=2, padx=20, sticky="e") + self.mode_badge.grid(row=0, column=1, rowspan=2, padx=14, sticky="e") content = ctk.CTkFrame(self.main_panel, fg_color="transparent") content.grid(row=1, column=0, padx=outer_pad, pady=(0, inner_pad), sticky="nsew") @@ -900,7 +923,7 @@ class SecureSmsApp(ctk.CTk): else: self.chat_container.grid(row=0, column=0, sticky="nsew", padx=(0, inner_pad)) - self.profile_card = ctk.CTkFrame(content, fg_color=CARD, corner_radius=24, border_width=1, border_color=BORDER) + self.profile_card = ctk.CTkFrame(content, fg_color=CARD, corner_radius=12, border_width=1, border_color=BORDER) self.profile_card.grid(row=1, column=0, sticky="ew") if self.is_portrait else self.profile_card.grid(row=0, column=1, sticky="nsew") self.profile_card.grid_columnconfigure(0, weight=1) self.profile_title_label = RTLLabel( @@ -937,54 +960,60 @@ class SecureSmsApp(ctk.CTk): text='فعال\u200cسازی ارتباط امن', fg_color=PRIMARY, hover_color=PRIMARY_DARK, - font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"), + text_color="#FFFFFF", + corner_radius=8, + font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), command=self._toggle_secure_mode, - height=48, + height=42, ) - self.secure_button.grid(row=4, column=0, padx=18, pady=(0, 10), sticky="ew") + self.secure_button.grid(row=4, column=0, padx=16, pady=(0, 8), sticky="ew") self.normal_button = RTLButton( self.profile_card, text='بازگشت به حالت عادی', - fg_color="#F4EFE9", + fg_color=INPUT_BG, text_color=TEXT, - hover_color="#ECE1D5", - font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"), + hover_color="#2D3A49", + corner_radius=8, + font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), command=self._switch_to_normal, - height=44, + height=40, ) - self.normal_button.grid(row=5, column=0, padx=18, pady=(0, 16), sticky="ew") + self.normal_button.grid(row=5, column=0, padx=16, pady=(0, 14), sticky="ew") self._configure_profile_card_layout() - composer = ctk.CTkFrame(self.main_panel, fg_color=CARD, corner_radius=24, border_width=1, border_color=BORDER) - composer.grid(row=2, column=0, padx=outer_pad, pady=(0, outer_pad), sticky="ew") + composer = ctk.CTkFrame(self.main_panel, fg_color=CARD, corner_radius=0, border_width=0) + composer.grid(row=2, column=0, sticky="ew") composer.grid_columnconfigure(0, weight=1) self.message_entry = RTLTextbox( composer, - height=72 if self.is_portrait else 82, + height=52 if self.is_portrait else 62, fg_color=INPUT_BG, + text_color=TEXT, border_width=0, - font=ctk.CTkFont(family=FONT_BODY, size=16), + corner_radius=10, + font=ctk.CTkFont(family=FONT_BODY, size=15), wrap="word", ) - self.message_entry.grid(row=0, column=0, padx=(16, inner_pad), pady=14 if self.is_portrait else 16, sticky="ew") + self.message_entry.grid(row=0, column=0, padx=(10, 6), pady=8, sticky="ew") actions = ctk.CTkFrame(composer, fg_color="transparent") - actions.grid(row=0, column=1, padx=(0, 16), pady=14 if self.is_portrait else 16, sticky="ns") + actions.grid(row=0, column=1, padx=(0, 10), pady=8, sticky="ns") self.send_state_label = RTLLabel( actions, text="", text_color=MUTED, - font=ctk.CTkFont(family=FONT_BODY, size=14), + font=ctk.CTkFont(family=FONT_BODY, size=11), ) - self.send_state_label.pack(pady=(4, 8)) + self.send_state_label.pack(pady=(2, 4)) RTLButton( actions, - text='ارسال', - fg_color=ACCENT, - text_color="#3A2514", - hover_color=ACCENT_DARK, - font=ctk.CTkFont(family=FONT_BODY, size=18, weight="bold"), - width=110 if self.is_portrait else 126, + text='➤', + fg_color=PRIMARY, + text_color="#FFFFFF", + hover_color=PRIMARY_DARK, + font=ctk.CTkFont(family=FONT_BODY, size=20, weight="bold"), + width=48, height=48, + corner_radius=24, command=self._send_message, ).pack() self.message_entry.bind("", lambda _event: self._send_message()) @@ -993,7 +1022,7 @@ class SecureSmsApp(ctk.CTk): self.overlay_frame = ctk.CTkFrame( self.main_panel, fg_color=CARD, - corner_radius=28, + corner_radius=12, border_width=1, border_color=BORDER, ) @@ -1015,9 +1044,9 @@ class SecureSmsApp(ctk.CTk): def _refresh_connection_badge(self): modem = self.controller.modem_status() if modem["connected"]: - self.connection_badge.configure(text=f"مودم متصل | {modem['port']}", fg_color="#2E7D62") + self.connection_badge.configure(text=f"مودم متصل | {modem['port']}", fg_color="#1C3A4F", text_color="#2AABEE") else: - self.connection_badge.configure(text=f"مودم آفلاین | {modem['port']}", fg_color="#9A6C3C") + self.connection_badge.configure(text=f"مودم آفلاین | {modem['port']}", fg_color="#3A2020", text_color="#E05D57") def _refresh_contacts(self): for widget in self.contacts_frame.winfo_children(): @@ -1037,15 +1066,15 @@ class SecureSmsApp(ctk.CTk): self.contacts_frame, text=f"{contact.name}\n{contact.phone}\n{contact.last_message_preview or 'آماده گفتگو'}", anchor="e", - height=76 if self.is_portrait else 88, - corner_radius=20, + height=72 if self.is_portrait else 80, + corner_radius=8, command=lambda phone=contact.phone: self._select_contact(phone), - fg_color="#F7EFE5" if selected else "#2A6956", - hover_color="#F0E3D5" if selected else "#32745F", - text_color=TEXT if selected else "white", - font=ctk.CTkFont(family=FONT_BODY, size=15), + fg_color="#2B5278" if selected else "transparent", + hover_color="#2B5278", + text_color=TEXT, + font=ctk.CTkFont(family=FONT_BODY, size=14), ) - card.grid(row=index, column=0, padx=8, pady=6, sticky="ew") + card.grid(row=index, column=0, padx=4, pady=2, sticky="ew") def _select_contact(self, phone: str): self.current_contact_phone = phone @@ -1093,51 +1122,52 @@ class SecureSmsApp(ctk.CTk): self.chat_container, text='هنوز پیامی ثبت نشده است.\nاز نوار پایین برای نوشتن پیام استفاده کن.', text_color=MUTED, - font=ctk.CTkFont(family=FONT_BODY, size=15) + font=ctk.CTkFont(family=FONT_BODY, size=14) ).pack(pady=40) return for message in messages: if message.direction == "system": + sys_frame = ctk.CTkFrame(self.chat_container, fg_color="#1C2733", corner_radius=8) + sys_frame.pack(pady=6, anchor="center") RTLLabel( - self.chat_container, + sys_frame, text=message.body, - text_color="#8A5C2E", - font=ctk.CTkFont(family=FONT_BODY, size=13), - wraplength=int(self.window_width * 0.7) - ).pack(pady=12, anchor="center") + text_color=MUTED, + font=ctk.CTkFont(family=FONT_BODY, size=12), + wraplength=int(self.window_width * 0.6) + ).pack(padx=12, pady=6) continue is_out = message.direction == "out" - bubble_color = "#E1F2E9" if is_out else "#FFFFFF" + bubble_color = BUBBLE_OUT if is_out else BUBBLE_IN anchor = "e" if is_out else "w" bubble = ctk.CTkFrame( self.chat_container, fg_color=bubble_color, - corner_radius=16, - border_width=1 if not is_out else 0, - border_color=BORDER + corner_radius=12, + border_width=0, ) - bubble.pack(anchor=anchor, padx=12, pady=6, fill="none") + bubble.pack(anchor=anchor, padx=8, pady=3, fill="none") RTLLabel( bubble, text=message.body, - text_color="#16312A", - font=ctk.CTkFont(family=FONT_BODY, size=16), - wraplength=max(200, int(self.window_width * 0.55)), + text_color="#FFFFFF", + font=ctk.CTkFont(family=FONT_BODY, size=15), + wraplength=max(180, int(self.window_width * 0.5)), justify="right" - ).pack(padx=16, pady=(12, 4), anchor="e") + ).pack(padx=12, pady=(8, 2), anchor="e") badge_text = f"🛡️ {message.created_at}" if message.mode == "secure" else message.created_at RTLLabel( bubble, text=badge_text, - text_color=MUTED, - font=ctk.CTkFont(family=FONT_BODY, size=11), + text_color="#7A8E9C", + font=ctk.CTkFont(family=FONT_BODY, size=10), justify="right" - ).pack(padx=16, pady=(0, 8), anchor="w" if is_out else "e") + ).pack(padx=12, pady=(0, 6), anchor="w" if is_out else "e") try: self.after(50, lambda: self.chat_container._parent_canvas.yview_moveto(1.0)) @@ -1255,9 +1285,9 @@ class SecureSmsApp(ctk.CTk): RTLButton( body, text='بازگشت به گفتگو', - fg_color="#F1E7DB", + fg_color=INPUT_BG, text_color=TEXT, - hover_color="#E8D8C7", + hover_color="#2D3A49", font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), height=40, command=self._hide_overlay, @@ -1286,9 +1316,9 @@ class SecureSmsApp(ctk.CTk): text='بستن', width=86, height=36, - fg_color="#F1E7DB", + fg_color=INPUT_BG, text_color=TEXT, - hover_color="#E8D8C7", + hover_color="#2D3A49", font=ctk.CTkFont(family=FONT_BODY, size=14, weight="bold"), command=self._hide_overlay, ).grid(row=0, column=1, padx=(8, 0), sticky="e") @@ -1353,9 +1383,9 @@ class SecureSmsApp(ctk.CTk): RTLButton( body, text='بازگشت به تنظیمات', - fg_color="#F1E7DB", + fg_color=INPUT_BG, text_color=TEXT, - hover_color="#E8D8C7", + hover_color="#2D3A49", font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), height=40, command=self._open_settings_panel, diff --git a/secure_sms/ui/__init__.py b/secure_sms/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/secure_sms/ui/core.py b/secure_sms/ui/core.py new file mode 100644 index 0000000..37815f2 --- /dev/null +++ b/secure_sms/ui/core.py @@ -0,0 +1,108 @@ +import os +import re +from tkinter import TclError + +import customtkinter as ctk + +try: + import arabic_reshaper + from bidi.algorithm import get_display +except ImportError: + arabic_reshaper = None + get_display = None + + +ctk.set_appearance_mode("light") +ctk.set_default_color_theme("blue") + + +PRIMARY = "#175B4B" +PRIMARY_DARK = "#0E4236" +PRIMARY_SOFT = "#DFF1E8" +ACCENT = "#E8A04D" +ACCENT_DARK = "#C97E2D" +BACKGROUND = "#F5EFE7" +CARD = "#FFFDFC" +SURFACE = "#FBF7F2" +INPUT_BG = "#FFFCF8" +TEXT = "#16312A" +MUTED = "#6B7A77" +DANGER = "#B6465F" +WARNING = "#9A6C3C" +BORDER = "#E5DCCE" + +KEYBOARD_BG = "#D4DCE2" +KEY_FACE = "#FFFFFF" +KEY_MUTED = "#BCC1C9" +KEY_TEXT = "#000000" + +SIDEBAR = "#1B5A4A" +SIDEBAR_SOFT = "#245E4E" + +FONT_BODY = "Tahoma" if os.name == "nt" else "DejaVu Sans" +FONT_TITLE = "Tahoma" if os.name == "nt" else "DejaVu Sans" + +RTL_PATTERN = re.compile(r"[\u0600-\u06FF]") + + +def ui_text(value): + if not isinstance(value, str) or not value: + return value + if arabic_reshaper is None or get_display is None or not RTL_PATTERN.search(value): + return value + return get_display(arabic_reshaper.reshape(value)) + + +class _RTLTextMixin: + @staticmethod + def _normalize_kwargs(kwargs): + normalized = dict(kwargs) + if "text" in normalized: + normalized["text"] = ui_text(normalized["text"]) + if "placeholder_text" in normalized: + normalized["placeholder_text"] = ui_text(normalized["placeholder_text"]) + if "label_text" in normalized: + normalized["label_text"] = ui_text(normalized["label_text"]) + return normalized + + +class RTLLabel(_RTLTextMixin, ctk.CTkLabel): + def __init__(self, *args, **kwargs): + super().__init__(*args, **self._normalize_kwargs(kwargs)) + + def configure(self, require_redraw=False, **kwargs): + return super().configure(require_redraw=require_redraw, **self._normalize_kwargs(kwargs)) + + +class RTLButton(_RTLTextMixin, ctk.CTkButton): + def __init__(self, *args, **kwargs): + kwargs.setdefault("corner_radius", 16) + super().__init__(*args, **self._normalize_kwargs(kwargs)) + + def configure(self, require_redraw=False, **kwargs): + return super().configure(require_redraw=require_redraw, **self._normalize_kwargs(kwargs)) + + +class RTLEntry(_RTLTextMixin, ctk.CTkEntry): + def __init__(self, *args, **kwargs): + kwargs.setdefault("justify", "right") + kwargs.setdefault("fg_color", INPUT_BG) + kwargs.setdefault("border_color", BORDER) + kwargs.setdefault("corner_radius", 16) + super().__init__(*args, **self._normalize_kwargs(kwargs)) + + def configure(self, require_redraw=False, **kwargs): + return super().configure(require_redraw=require_redraw, **self._normalize_kwargs(kwargs)) + + +class RTLTextbox(ctk.CTkTextbox): + def insert(self, index, text, *tags): + return super().insert(index, ui_text(text), *tags) + + +class RTLScrollableFrame(_RTLTextMixin, ctk.CTkScrollableFrame): + def __init__(self, *args, **kwargs): + super().__init__(*args, **self._normalize_kwargs(kwargs)) + + def configure(self, require_redraw=False, **kwargs): + return super().configure(require_redraw=require_redraw, **self._normalize_kwargs(kwargs))