commit 2f128f9a1a6eabe3a3d2bafb5496738d69ece834 Author: MOJ1403 Date: Mon Mar 23 19:29:24 2026 +0330 avalin proge diff --git a/App_GUI.py b/App_GUI.py new file mode 100644 index 0000000..0170012 --- /dev/null +++ b/App_GUI.py @@ -0,0 +1,249 @@ +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 new file mode 100644 index 0000000..e9a507c --- /dev/null +++ b/Crypto_Engine.py @@ -0,0 +1,162 @@ +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 new file mode 100644 index 0000000..7f43392 --- /dev/null +++ b/DB_Handler.py @@ -0,0 +1,134 @@ +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 new file mode 100644 index 0000000..5bed997 --- /dev/null +++ b/GSM_Manager.py @@ -0,0 +1,218 @@ +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/__pycache__/App_GUI.cpython-313.pyc b/__pycache__/App_GUI.cpython-313.pyc new file mode 100644 index 0000000..f15e825 Binary files /dev/null and b/__pycache__/App_GUI.cpython-313.pyc differ diff --git a/__pycache__/Crypto_Engine.cpython-313.pyc b/__pycache__/Crypto_Engine.cpython-313.pyc new file mode 100644 index 0000000..1862346 Binary files /dev/null and b/__pycache__/Crypto_Engine.cpython-313.pyc differ diff --git a/__pycache__/DB_Handler.cpython-313.pyc b/__pycache__/DB_Handler.cpython-313.pyc new file mode 100644 index 0000000..2c4ccb0 Binary files /dev/null and b/__pycache__/DB_Handler.cpython-313.pyc differ diff --git a/__pycache__/GSM_Manager.cpython-313.pyc b/__pycache__/GSM_Manager.cpython-313.pyc new file mode 100644 index 0000000..d6c621a Binary files /dev/null and b/__pycache__/GSM_Manager.cpython-313.pyc differ diff --git a/__pycache__/main.cpython-313.pyc b/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..d6223f6 Binary files /dev/null and b/__pycache__/main.cpython-313.pyc differ diff --git a/_bad_strings.txt b/_bad_strings.txt new file mode 100644 index 0000000..00e3ead --- /dev/null +++ b/_bad_strings.txt @@ -0,0 +1,7 @@ +57: ? +276: ????? +278: ????? +280: ???? +281: ??? +283: ??? +284: ????? \ No newline at end of file diff --git a/_question_strings.txt b/_question_strings.txt new file mode 100644 index 0000000..dba873c --- /dev/null +++ b/_question_strings.txt @@ -0,0 +1,6 @@ +276|????? +278|????? +280|???? +281|??? +283|??? +284|????? \ No newline at end of file diff --git a/_string_tokens.txt b/_string_tokens.txt new file mode 100644 index 0000000..a7f8c04 --- /dev/null +++ b/_string_tokens.txt @@ -0,0 +1,643 @@ +15|light +16|blue +19|#175B4B +20|#0E4236 +21|#DFF1E8 +22|#E8A04D +23|#C97E2D +24|#F5EFE7 +25|#FFFDFC +26|#FBF7F2 +27|#FFFCF8 +28|#16312A +29|#6B7A77 +30|#B6465F +31|#9A6C3C +32|#E5DCCE +33|#E7DED3 +34|#FFF8F0 +35|#F2E7D8 +37|#1B5A4A +38|#245E4E +39|DejaVu Sans +40|DejaVu Sans +41|[\\u0600-\\u06FF] +43|fa +44|\u0636 +44|\u0635 +44|\u062b +44|\u0642 +44|\u0641 +44|\u063a +44|\u0639 +44|\u0647 +44|\u062e +44|\u062d +45|\u0634 +45|\u0633 +45|\u06cc +45|\u0628 +45|\u0644 +45|\u0627 +45|\u062a +45|\u0646 +45|\u0645 +45|\u06a9 +46|123 +46|\u0638 +46|\u0637 +46|\u0632 +46|\u0631 +46|\u0630 +46|\u062f +46|\u067e +46|\u0648 +46|\u067e\u0627\u06a9 +47|English +47|\u060c +47|\u0641\u0627\u0635\u0644\u0647 +47|\u062a\u0627\u06cc\u06cc\u062f +49|en +50|q +50|w +50|e +50|r +50|t +50|y +50|u +50|i +50|o +50|p +51|a +51|s +51|d +51|f +51|g +51|h +51|j +51|k +51|l +52|123 +52|z +52|x +52|c +52|v +52|b +52|n +52|m +52|Back +53|\u0641\u0627\u0631\u0633\u06cc +53|. +53|Space +53|Enter +55|numeric +56|1 +56|2 +56|3 +56|4 +56|5 +56|6 +56|7 +56|8 +56|9 +56|0 +57|+ +57|- +57|/ +57|@ +57|_ +57|. +57|: +57|( +57|) +57|? +58|\u0641\u0627\u0631\u0633\u06cc +58|English +58|Back +59|\u0628\u0633\u062a\u0646 +59|\u0641\u0627\u0635\u0644\u0647 +59|\u062a\u0627\u06cc\u06cc\u062f +63|\u0641\u0627\u0631\u0633\u06cc +63|fa +64|English +64|en +65|123 +65|numeric +66|\u067e\u0627\u06a9 +66|backspace +67|\u0628\u0633\u062a\u0646 +67|close +83|text +84|text +84|text +85|placeholder_text +86|placeholder_text +86|placeholder_text +87|label_text +88|label_text +88|label_text +102|corner_radius +111|justify +111|right +112|fg_color +113|border_color +114|corner_radius +140|fa +144|SECURE_SMS_WINDOWED +144|0 +144|1 +145|\u067e\u06cc\u0627\u0645\u200c\u0631\u0633\u0627\u0646 \u0627\u0645\u0646 \u0635\u0628\u0627 +146|800x480 +150|*Cursor +150|none +152| +152|+ +161|-topmost +162|-fullscreen +164|800x480+0+0 +169|none +176|_entry +176|_textbox +184|master +191|master +199|master +211|fa +213|title +214|layout +215|multiline +216|submit +221| +221|+ +222| +222|+ +234|layout +271|title +272|\u0648\u0631\u0648\u062f\u06cc +276|????? +277|Space +278|????? +279|Enter +280|???? +281|??? +282|Back +283|??? +284|????? +285|English +286|123 +290|\u062a\u0627\u06cc\u06cc\u062f +290|Enter +291|white +292|ABC +292|\u0641\u0627 +292|\u06f1\u06f2\u06f3 +292|123 +292|English +292|\u0641\u0627\u0631\u0633\u06cc +293|#3A2514 +294|\u062d\u0630\u0641 +294|\u0628\u0633\u062a\u0646 +294|Back +294|\u067e\u0627\u06a9 +295|#E4D4C2 +296|#F2E6D8 +299|fa +299|en +299|numeric +301|white +301|#3A2514 +302|backspace +303|#E4D4C2 +304|#F4EFE9 +304|#E8D8C7 +307|fa +307|en +307|numeric +309|backspace +311|close +328|bold +330|ew +336|fa +337|transparent +338|ew +349|bold +351|ew +354|ABC +354|English +355|en +357|\u0641\u0627 +357|\u0641\u0627\u0631\u0633\u06cc +358|fa +360|\u06f1\u06f2\u06f3 +360|123 +361|numeric +363|\u0641\u0627\u0635\u0644\u0647 +363|Space +364| +366|\u062d\u0630\u0641 +366|Back +366|\u067e\u0627\u06a9 +369|\u0628\u0633\u062a\u0646 +372|\u062a\u0627\u06cc\u06cc\u062f +372|Enter +384|sel +387|insert +388|insert +392|sel.first +392|sel.last +395|insert +407|sel +410|insert +410|> +410|1.0 +411|insert-1c +412|insert +416|sel.first +416|sel.last +421|insert +433|submit +437|multiline +438|\n +448|nsew +453|ew +463|ew +466|transparent +467|ew +472|\u06a9\u06cc\u0628\u0648\u0631\u062f \u0644\u0645\u0633\u06cc +474|bold +475|e +478|\u0628\u0633\u062a\u0646 +482|#E4D4C2 +484|bold +486|e +490| +494|e +496|transparent +497|ew +500|transparent +501|ew +522|center +523|\u0631\u0627\u0647\u200c\u0627\u0646\u062f\u0627\u0632\u06cc \u0627\u0645\u0646 \u0628\u0631\u0646\u0627\u0645\u0647 +523|\u0648\u0631\u0648\u062f \u0628\u0647 \u0628\u0631\u0646\u0627\u0645\u0647 +525|\u06cc\u06a9 \u0631\u0645\u0632 \u0627\u0635\u0644\u06cc \u062a\u0639\u06cc\u06cc\u0646 \u06a9\u0646 \u062a\u0627 \u06a9\u0644\u06cc\u062f\u0647\u0627 \u0648 \u062f\u0627\u062f\u0647\u200c\u0647\u0627\u06cc \u062d\u0633\u0627\u0633 \u062f\u0627\u062e\u0644 \u062f\u06cc\u062a\u0627\u0628\u06cc\u0633 \u0628\u0647 \u0635\u0648\u0631\u062a \u0631\u0645\u0632\u200c\u0634\u062f\u0647 \u0646\u06af\u0647\u200c\u062f\u0627\u0631\u06cc \u0634\u0648\u0646\u062f. +527|\u0628\u0631\u0627\u06cc \u0648\u0631\u0648\u062f\u060c \u0631\u0645\u0632 \u0627\u0635\u0644\u06cc \u0628\u0631\u0646\u0627\u0645\u0647 \u0631\u0627 \u0648\u0627\u0631\u062f \u06a9\u0646. +532|bold +539|right +545|\u0631\u0645\u0632 \u0627\u0635\u0644\u06cc +546|* +550|x +555|\u062a\u06a9\u0631\u0627\u0631 \u0631\u0645\u0632 +556|* +560|x +563| +568|\u0634\u0631\u0648\u0639 \u0628\u0631\u0646\u0627\u0645\u0647 +568|\u0648\u0631\u0648\u062f +576|bold +577|x +578| +579|\u0631\u0645\u0632 \u0627\u0635\u0644\u06cc +579|en +581| +582|\u062a\u06a9\u0631\u0627\u0631 \u0631\u0645\u0632 +582|en +587|\u0631\u0645\u0632 \u0628\u0627\u06cc\u062f \u062d\u062f\u0627\u0642\u0644 \u06f6 \u06a9\u0627\u0631\u0627\u06a9\u062a\u0631 \u0628\u0627\u0634\u062f. +592|\u062a\u06a9\u0631\u0627\u0631 \u0631\u0645\u0632 \u0628\u0627 \u0631\u0645\u0632 \u0627\u0635\u0644\u06cc \u06cc\u06a9\u0633\u0627\u0646 \u0646\u06cc\u0633\u062a. +600|\u0631\u0645\u0632 \u0648\u0627\u0631\u062f \u0634\u062f\u0647 \u062f\u0631\u0633\u062a \u0646\u06cc\u0633\u062a. +608|nsew +613|\xd8\xb5\xd8\xa8\xd8\xa7 +614|white +615|bold +616|e +619|\u067e\u06cc\u0627\u0645\u200c\u0631\u0633\u0627\u0646 \u0627\u0645\u0646 \u0648 \u0633\u0627\u062f\u0647 \u0628\u0631\u0627\u06cc \u06a9\u0627\u0631\u0628\u0631 \u063a\u06cc\u0631 \u0641\u0646\u06cc +620|#D5E8E1 +622|e +626| +628|#2E7D62 +629|white +630|bold +634|e +636|transparent +637|ew +641|\u0645\u062e\u0627\u0637\u0628 \u062c\u062f\u06cc\u062f +644|#3A2514 +647|bold +648|ew +651|\u062a\u0646\u0638\u06cc\u0645\u0627\u062a +653|#F4EFE9 +654|#15302B +655|#ECE1D5 +657|bold +658|ew +665|#3B7B66 +667|ew +671|\u0645\u062e\u0627\u0637\u0628 \u062c\u062f\u06cc\u062f +672|white +673|bold +674|e +677|\u0646\u0627\u0645 \u0645\u062e\u0627\u0637\u0628 +681|ew +684|\u0634\u0645\u0627\u0631\u0647 \u0645\u0648\u0628\u0627\u06cc\u0644 +688|ew +691| +692|#FDE68A +695|e +696|transparent +697|ew +701|\u0630\u062e\u06cc\u0631\u0647 +703|#3A2514 +706|bold +708|ew +711|\xd8\xa8\xd8\xb3\xd8\xaa\xd9\u2020 +712|#F4EFE9 +714|#ECE1D5 +716|bold +718|ew +719| +720|\u0646\u0627\u0645 \u0645\u062e\u0627\u0637\u0628 +720|fa +723|\u0634\u0645\u0627\u0631\u0647 \u0645\u0648\u0628\u0627\u06cc\u0644 +724|numeric +731|\u0645\u062e\u0627\u0637\u0628\u200c\u0647\u0627 +732|bold +733|transparent +735|nsew +738|nsew +743|ew +748|\u06cc\u06a9 \u0645\u062e\u0627\u0637\u0628 \u0631\u0627 \u0627\u0646\u062a\u062e\u0627\u0628 \u06a9\u0646 +750|bold +752|e +755|\u062f\u0631 \u0627\u06cc\u0646\u062c\u0627 \u0641\u0642\u0637 \u062f\u0648 \u062d\u0627\u0644\u062a \u062f\u0627\u0631\u06cc: \u0639\u0627\u062f\u06cc \u06cc\u0627 \u0627\u0645\u0646 +759|e +762|\xd8\xb9\xd8\xa7\xd8\xaf\xdb\u0152 +768|bold +770|e +772|transparent +773|nsew +785|word +787|nsew +788|out +788|right +789|in +789|right +790|system +790|#8A5C2E +790|right +791|disabled +794|nsew +798|\u067e\u0631\u0648\u0641\u0627\u06cc\u0644 \u0645\u062e\u0627\u0637\u0628 +800|bold +801|e +804|\u0646\u0627\u0645 \u0645\u062e\u0627\u0637\u0628 +806|bold +808|e +811|\u0634\u0645\u0627\u0631\u0647 +815|e +818|\u0628\u0631\u0627\u06cc \u0627\u06cc\u0646 \u0645\u062e\u0627\u0637\u0628 \u0647\u0646\u0648\u0632 \u062d\u0627\u0644\u062a \u0627\u0645\u0646 \u0641\u0639\u0627\u0644 \u0646\u0634\u062f\u0647 \u0627\u0633\u062a. +820|right +824|e +827|\u0641\u0639\u0627\u0644\u200c\u0633\u0627\u0632\u06cc \u0627\u0631\u062a\u0628\u0627\u0637 \u0627\u0645\u0646 +830|bold +834|ew +837|\u0628\u0627\u0632\u06af\u0634\u062a \u0628\u0647 \u062d\u0627\u0644\u062a \u0639\u0627\u062f\u06cc +838|#F4EFE9 +840|#ECE1D5 +841|bold +845|ew +848|ew +856|word +858|ew +859|transparent +860|ns +863| +870|\xd8\xa7\xd8\xb1\xd8\xb3\xd8\xa7\xd9\u201e +872|#3A2514 +874|bold +879| +880|\u0645\u062a\u0646 \u067e\u06cc\u0627\u0645 +880|fa +889|nsew +906|connected +907|port +907|#2E7D62 +909|port +909|#9A6C3C +918|\u0647\u0646\u0648\u0632 \u0645\u062e\u0627\u0637\u0628\u06cc \u0627\u0636\u0627\u0641\u0647 \u0646\u0634\u062f\u0647 \u0627\u0633\u062a. +919|#D5E8E1 +921|e +927|\u0622\u0645\u0627\u062f\u0647 \u06af\u0641\u062a\u06af\u0648 +928|e +932|#F7EFE5 +932|#2A6956 +933|#F0E3D5 +933|#32745F +934|white +937|ew +946|\u06cc\u06a9 \u0645\u062e\u0627\u0637\u0628 \u0631\u0627 \u0627\u0646\u062a\u062e\u0627\u0628 \u06a9\u0646 +947|\u062f\u0631 \u0627\u06cc\u0646\u062c\u0627 \u0641\u0642\u0637 \u062f\u0648 \u062d\u0627\u0644\u062a \u062f\u0627\u0631\u06cc: \u0639\u0627\u062f\u06cc \u06cc\u0627 \u0627\u0645\u0646 +948|\xd8\xb9\xd8\xa7\xd8\xaf\xdb\u0152 +949|\u0646\u0627\u0645 \u0645\u062e\u0627\u0637\u0628 +950|\u0634\u0645\u0627\u0631\u0647 +951|\u0628\u0631\u0627\u06cc \u0634\u0631\u0648\u0639\u060c \u06cc\u06a9 \u0645\u062e\u0627\u0637\u0628 \u0627\u0632 \u0633\u062a\u0648\u0646 \u0633\u0645\u062a \u0631\u0627\u0633\u062a \u0627\u0646\u062a\u062e\u0627\u0628 \u06a9\u0646. +960|pending +961|\xd8\xaf\xd8\xb1 \xd8\xa7\xd9\u2020\xd8\xaa\xd8\xb8\xd8\xa7\xd8\xb1 +961|#FCEBD7 +961|#9A6C3C +962|\u062f\u0631\u062e\u0648\u0627\u0633\u062a \u0627\u0631\u062a\u0628\u0627\u0637 \u0627\u0645\u0646 \u0627\u0631\u0633\u0627\u0644 \u0634\u062f\u0647 \u0648 \u0628\u0631\u0646\u0627\u0645\u0647 \u0645\u0646\u062a\u0638\u0631 \u067e\u0627\u0633\u062e \u0637\u0631\u0641 \u0645\u0642\u0627\u0628\u0644 \u0627\u0633\u062a. +963|secure +964|\u0627\u0645\u0646 +964|#D9F5E8 +964|#0F8A5F +965|\u0627\u0631\u062a\u0628\u0627\u0637 \u0627\u0645\u0646 \u0641\u0639\u0627\u0644 \u0627\u0633\u062a. \u0647\u0631 \u0632\u0645\u0627\u0646 \u0628\u062e\u0648\u0627\u0647\u06cc \u0645\u06cc\u200c\u062a\u0648\u0627\u0646\u06cc \u0628\u0647 \u062d\u0627\u0644\u062a \u0639\u0627\u062f\u06cc \u0628\u0631\u06af\u0631\u062f\u06cc. +967|\xd8\xb9\xd8\xa7\xd8\xaf\xdb\u0152 +969|\u06a9\u0644\u06cc\u062f \u0627\u06cc\u0646 \u0645\u062e\u0627\u0637\u0628 \u0622\u0645\u0627\u062f\u0647 \u0627\u0633\u062a. \u0627\u06af\u0631 \u0628\u062e\u0648\u0627\u0647\u06cc \u0645\u06cc\u200c\u062a\u0648\u0627\u0646\u06cc \u062f\u0648\u0628\u0627\u0631\u0647 \u0627\u0631\u062a\u0628\u0627\u0637 \u0627\u0645\u0646 \u0631\u0627 \u0641\u0639\u0627\u0644 \u06a9\u0646\u06cc. +971|\u0628\u0631\u0627\u06cc \u0627\u0645\u0646 \u0634\u062f\u0646 \u06af\u0641\u062a\u06af\u0648\u060c \u0641\u0642\u0637 \u0631\u0648\u06cc \u062f\u06a9\u0645\u0647 \u0641\u0639\u0627\u0644\u200c\u0633\u0627\u0632\u06cc \u0627\u0631\u062a\u0628\u0627\u0637 \u0627\u0645\u0646 \u0628\u0632\u0646. +972|normal +973|normal +973|secure +973|pending +973|disabled +977|normal +978|1.0 +978|end +980|end +980|\n\u0647\u0646\u0648\u0632 \u067e\u06cc\u0627\u0645\u06cc \u062b\u0628\u062a \u0646\u0634\u062f\u0647 \u0627\u0633\u062a.\n +980|system +982|system +983|end +983|system +985|\xd8\xb4\xd9\u2026\xd8\xa7 +985|out +985|\xd9\u2026\xd8\xae\xd8\xa7\xd8\xb7\xd8\xa8 +986| | \u0627\u0645\u0646 +986|secure +986| +987|out +987|out +987|in +989|end +993|disabled +994|end +998|\u0627\u0648\u0644 \u06cc\u06a9 \u0645\u062e\u0627\u0637\u0628 \u0631\u0627 \u0627\u0646\u062a\u062e\u0627\u0628 \u06a9\u0646. +1000|1.0 +1000|end-1c +1002|\u0645\u062a\u0646 \u067e\u06cc\u0627\u0645 \u062e\u0627\u0644\u06cc \u0627\u0633\u062a. +1006|1.0 +1006|end +1007|\xd8\xa7\xd8\xb1\xd8\xb3\xd8\xa7\xd9\u201e \xd8\xb4\xd8\xaf. +1007|sent +1007|\u062f\u0631 \u062d\u0627\u0644\u062a \u0622\u0641\u0644\u0627\u06cc\u0646\u060c \u067e\u06cc\u0627\u0645 \u0628\u0647 \u0635\u0648\u0631\u062a \u0634\u0628\u06cc\u0647\u200c\u0633\u0627\u0632\u06cc \u062b\u0628\u062a \u0634\u062f. +1019|\u062f\u0631\u062e\u0648\u0627\u0633\u062a \u0627\u0631\u062a\u0628\u0627\u0637 \u0627\u0645\u0646 \u0627\u0631\u0633\u0627\u0644 \u0634\u062f. +1019|sent +1019|\u062f\u0631 \u062d\u0627\u0644\u062a \u0622\u0641\u0644\u0627\u06cc\u0646\u060c \u062f\u0631\u062e\u0648\u0627\u0633\u062a \u0627\u0645\u0646 \u0628\u0647 \u0635\u0648\u0631\u062a \u0645\u062d\u0644\u06cc \u062b\u0628\u062a \u0634\u062f. +1023|\u0627\u0631\u0633\u0627\u0644 \u062f\u0631\u062e\u0648\u0627\u0633\u062a \u0627\u0645\u0646 \u0646\u0627\u0645\u0648\u0641\u0642 \u0628\u0648\u062f. +1032|\u06af\u0641\u062a\u06af\u0648 \u0628\u0647 \u062d\u0627\u0644\u062a \u0639\u0627\u062f\u06cc \u0628\u0631\u06af\u0634\u062a. +1032|sent +1032|\u062f\u0631 \u062d\u0627\u0644\u062a \u0622\u0641\u0644\u0627\u06cc\u0646\u060c \u0628\u0627\u0632\u06af\u0634\u062a \u0628\u0647 \u0639\u0627\u062f\u06cc \u0645\u062d\u0644\u06cc \u062b\u0628\u062a \u0634\u062f. +1036|\u0628\u0627\u0632\u06af\u0634\u062a \u0628\u0647 \u062d\u0627\u0644\u062a \u0639\u0627\u062f\u06cc \u0627\u0646\u062c\u0627\u0645 \u0646\u0634\u062f. +1043| +1044|end +1045|end +1052| +1058|\u0646\u0627\u0645 \u0648 \u0634\u0645\u0627\u0631\u0647 \u0647\u0631 \u062f\u0648 \u0644\u0627\u0632\u0645 \u0647\u0633\u062a\u0646\u062f. +1062|\u0645\u062e\u0627\u0637\u0628 \u0630\u062e\u06cc\u0631\u0647 \u0634\u062f. +1063|end +1064|end +1072|\u062a\u0646\u0638\u06cc\u0645\u0627\u062a +1073|\u0647\u0645\u0647 \u0628\u062e\u0634\u200c\u0647\u0627 \u062f\u0627\u062e\u0644 \u0647\u0645\u06cc\u0646 \u067e\u0646\u062c\u0631\u0647 \u0628\u0627\u0632 \u0645\u06cc\u200c\u0634\u0648\u0646\u062f \u062a\u0627 \u0628\u0631\u0627\u06cc \u0646\u0645\u0627\u06cc\u0634\u06af\u0631 \u06f7 \u0627\u06cc\u0646\u0686\u06cc \u0633\u0627\u062f\u0647 \u0648 \u0642\u0627\u0628\u0644 \u0644\u0645\u0633 \u0628\u0645\u0627\u0646\u0646\u062f. +1075|x +1078|both +1083|\u0648\u0636\u0639\u06cc\u062a \u0641\u0639\u0644\u06cc \u062f\u0633\u062a\u06af\u0627\u0647 +1085|bold +1086|e +1089|\u0645\u062a\u0635\u0644 +1089|connected +1089|\u0622\u0641\u0644\u0627\u06cc\u0646 +1089|port +1089|baudrate +1092|right +1094|e +1097|\u0648\u0631\u0648\u062f \u0628\u0647 \u067e\u0646\u0644 \u0627\u062f\u0645\u06cc\u0646 +1100|bold +1103|x +1106|\u0628\u0627\u0632\u06af\u0634\u062a \u0628\u0647 \u06af\u0641\u062a\u06af\u0648 +1107|#F1E7DB +1109|#E8D8C7 +1110|bold +1113|x +1131|transparent +1135|\xd8\xa8\xd8\xb3\xd8\xaa\xd9\u2020 +1138|#F1E7DB +1140|#E8D8C7 +1141|bold +1143|e +1148|bold +1149|e +1155|right +1157|e +1163|\u0648\u0631\u0648\u062f \u0627\u062f\u0645\u06cc\u0646 +1164|\u062c\u0632\u0626\u06cc\u0627\u062a \u0641\u0646\u06cc\u060c \u0644\u0627\u06af\u200c\u0647\u0627 \u0648 \u062a\u0646\u0638\u06cc\u0645\u0627\u062a \u0627\u0645\u0646\u06cc\u062a\u06cc \u0641\u0642\u0637 \u0628\u0639\u062f \u0627\u0632 \u0648\u0631\u0648\u062f \u0627\u062f\u0645\u06cc\u0646 \u0646\u0645\u0627\u06cc\u0634 \u062f\u0627\u062f\u0647 \u0645\u06cc\u200c\u0634\u0648\u0646\u062f. +1166|x +1169|both +1172|\u0631\u0645\u0632 \u0627\u0635\u0644\u06cc \u0631\u0627 \u0648\u0627\u0631\u062f \u06a9\u0646 +1174|bold +1175|e +1178|\u0631\u0645\u0632 \u0627\u0635\u0644\u06cc +1179|* +1183|x +1184| +1185|e +1189|\u0631\u0645\u0632 \u0627\u062f\u0645\u06cc\u0646 \u0635\u062d\u06cc\u062d \u0646\u06cc\u0633\u062a. +1195|\u0648\u0631\u0648\u062f \u0628\u0647 \u067e\u0646\u0644 \u0627\u062f\u0645\u06cc\u0646 +1198|bold +1201|x +1204|\u0628\u0627\u0632\u06af\u0634\u062a \u0628\u0647 \u062a\u0646\u0638\u06cc\u0645\u0627\u062a +1205|#F1E7DB +1207|#E8D8C7 +1208|bold +1211|x +1212|\u0631\u0645\u0632 \u0627\u0635\u0644\u06cc \u0627\u062f\u0645\u06cc\u0646 +1212|en +1217|\u067e\u0646\u0644 \u0627\u062f\u0645\u06cc\u0646 +1218|\u0627\u06cc\u0646 \u0628\u062e\u0634 \u0628\u0631\u0627\u06cc \u0646\u0645\u0627\u06cc\u0634\u06af\u0631 \u06a9\u0648\u0686\u06a9 \u062e\u0644\u0627\u0635\u0647 \u0634\u062f\u0647 \u062a\u0627 \u0627\u0637\u0644\u0627\u0639\u0627\u062a \u0627\u0635\u0644\u06cc\u060c \u0644\u0627\u06af\u200c\u0647\u0627 \u0648 \u062a\u0646\u0638\u06cc\u0645\u0627\u062a \u0645\u0648\u062f\u0645/\u0631\u0645\u0632 \u062f\u0627\u062e\u0644 \u0647\u0645\u0627\u0646 \u067e\u0646\u062c\u0631\u0647 \u062f\u0631 \u062f\u0633\u062a\u0631\u0633 \u0628\u0627\u0634\u0646\u062f. +1220|x +1223|stats +1224|system_info +1230| +1232|both +1236|\u06a9\u0644 \u0645\u062e\u0627\u0637\u0628\u200c\u0647\u0627 +1236|contacts +1237|\u0645\u062e\u0627\u0637\u0628 \u0627\u0645\u0646 +1237|secure_contacts +1238|\xd8\xaf\xd8\xb1 \xd8\xa7\xd9\u2020\xd8\xaa\xd8\xb8\xd8\xa7\xd8\xb1 +1238|pending_contacts +1239|\u067e\u06cc\u0627\u0645 \u0627\u0645\u0646 +1239|secure_messages +1243|ew +1249|e +1254|bold +1255|e +1258|ew +1261|\u0627\u0637\u0644\u0627\u0639\u0627\u062a \u0633\u06cc\u0633\u062a\u0645\u06cc +1263|bold +1264|e +1267|platform +1267|modem_port +1267|baudrate +1267|fingerprint +1269|right +1272|e +1275|ew +1278|\u0622\u062e\u0631\u06cc\u0646 \u0644\u0627\u06af\u200c\u0647\u0627 +1280|bold +1281|e +1282|word +1283|x +1284|events +1285|events +1286|end +1288|end +1288|\u0647\u0646\u0648\u0632 \u0644\u0627\u06af\u06cc \u062b\u0628\u062a \u0646\u0634\u062f\u0647 \u0627\u0633\u062a. +1289|disabled +1292|ew +1295|\u0628\u0633\u062a\u0647\u200c\u0647\u0627\u06cc \u0646\u0627\u0642\u0635 +1297|bold +1298|e +1299|word +1300|x +1301|pending_packets +1302|pending_packets +1303|end +1305|end +1305|\u067e\u06cc\u0627\u0645 \u0646\u0627\u0642\u0635\u06cc \u062f\u0631 \u0635\u0641 \u0648\u062c\u0648\u062f \u0646\u062f\u0627\u0631\u062f. +1306|disabled +1309|ew +1312|\u062a\u0646\u0638\u06cc\u0645\u0627\u062a \u0645\u0648\u062f\u0645 \u0648 \u0631\u0645\u0632 +1314|bold +1315|e +1316|\u067e\u0648\u0631\u062a \u0645\u0648\u062f\u0645 \u0645\u062b\u0644 COM3 +1317|x +1318|modem_port +1319|Baudrate +1320|x +1321|baudrate +1322|\u0631\u0645\u0632 \u0641\u0639\u0644\u06cc +1322|* +1323|x +1324|\u0631\u0645\u0632 \u062c\u062f\u06cc\u062f +1324|* +1325|x +1326| +1327|e +1328|\u067e\u0648\u0631\u062a \u0645\u0648\u062f\u0645 +1328|en +1329|Baudrate +1329|numeric +1330|\u0631\u0645\u0632 \u0641\u0639\u0644\u06cc +1330|en +1331|\u0631\u0645\u0632 \u062c\u062f\u06cc\u062f +1331|en +1339|\u062a\u063a\u06cc\u06cc\u0631\u0627\u062a \u0630\u062e\u06cc\u0631\u0647 \u0634\u062f \u0648 \u0645\u0648\u062f\u0645 \u0645\u062a\u0635\u0644 \u0627\u0633\u062a. +1341|\u062a\u0646\u0638\u06cc\u0645\u0627\u062a \u0630\u062e\u06cc\u0631\u0647 \u0634\u062f\u060c \u0627\u0645\u0627 \u0645\u0648\u062f\u0645 \u0647\u0646\u0648\u0632 \u0622\u0641\u0644\u0627\u06cc\u0646 \u0627\u0633\u062a. +1341|#9A6C3C +1347|\u0630\u062e\u06cc\u0631\u0647 \u062a\u063a\u06cc\u06cc\u0631\u0627\u062a +1350|bold +1353|x \ No newline at end of file diff --git a/a.ps1 b/a.ps1 new file mode 100644 index 0000000..1372882 --- /dev/null +++ b/a.ps1 @@ -0,0 +1,44 @@ +# ----------------------------- +# تنظیمات +# ----------------------------- +$LocalPath = "C:\Users\Pars\Desktop\saba-python" # مسیر پروژه روی ویندوز +$PiUser = "pars" # کاربر روی Raspberry Pi +$PiHost = "10.65.40.150" # آی‌پی Raspberry Pi +$RemotePath = "/home/pars/Desktop/" # مسیر پروژه روی Pi +$MainPy = "saba-python/main.py" # فایل اصلی پایتون + +$KeyPath = "$env:USERPROFILE\.ssh\id_ed25519" + +# ----------------------------- +# ساخت کلید SSH اگر موجود نباشد +# ----------------------------- +if (-not (Test-Path $KeyPath)) { + Write-Host "Generating SSH key..." + ssh-keygen -t ed25519 -f $KeyPath -N "" -C "$PiUser@windows" +} else { + Write-Host "SSH key already exists." +} + +# ----------------------------- +# اضافه کردن کلید به Pi (رمز یک بار لازم است) +# ----------------------------- +Write-Host "Copying public key to Raspberry Pi..." +$pubKey = Get-Content "$KeyPath.pub" +# دستور برای اضافه کردن کلید به authorized_keys روی Pi +$command = "mkdir -p ~/.ssh; chmod 700 ~/.ssh; echo '$pubKey' >> ~/.ssh/authorized_keys; chmod 600 ~/.ssh/authorized_keys" +ssh "$PiUser@$PiHost" $command + +# ----------------------------- +# انتقال پروژه با scp +# ----------------------------- +Write-Host "Transferring project to Raspberry Pi..." +$scpTarget = "$PiUser@$PiHost`:$RemotePath" +scp -r $LocalPath $scpTarget + +# ----------------------------- +# اجرای برنامه روی Raspberry Pi +# ----------------------------- +Write-Host "Running program on Raspberry Pi..." +ssh "$PiUser@$PiHost" "cd $RemotePath && python3 $MainPy" + +Write-Host "Deployment complete! Latest version is running on Raspberry Pi." -ForegroundColor Green \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..8ca2075 --- /dev/null +++ b/main.py @@ -0,0 +1,276 @@ +import os +import subprocess +import sys +from importlib.util import find_spec +from pathlib import Path +from tkinter import TclError + +BASE_DIR = Path(__file__).resolve().parent +REQUIREMENTS_FILE = BASE_DIR / "requirements.txt" +PROJECT_VENV_DIR = BASE_DIR / ".runtime-venv" + +REQUIRED_IMPORTS = { + "customtkinter": "customtkinter", + "cryptography": "cryptography", + "pyserial": "serial", + "arabic-reshaper": "arabic_reshaper", + "python-bidi": "bidi", +} + + +def _project_venv_python() -> Path: + if os.name == "nt": + return PROJECT_VENV_DIR / "Scripts" / "python.exe" + return PROJECT_VENV_DIR / "bin" / "python" + + +def _running_inside_virtualenv() -> bool: + return sys.prefix != getattr(sys, "base_prefix", sys.prefix) or hasattr(sys, "real_prefix") + + +def _missing_requirements() -> list[str]: + missing = [] + for package_name, import_name in REQUIRED_IMPORTS.items(): + if find_spec(import_name) is None: + missing.append(package_name) + return missing + + +def _missing_requirements_for_python(python_executable: str) -> tuple[list[str] | None, str | None]: + check_script = "\n".join( + [ + "from importlib.util import find_spec", + f"required = {REQUIRED_IMPORTS!r}", + "missing = [name for name, import_name in required.items() if find_spec(import_name) is None]", + "print('\\n'.join(missing))", + ] + ) + ok, output = _run_subprocess( + [python_executable, "-c", check_script], + "Checking project virtual environment", + ) + if not ok: + return None, output + return [line.strip() for line in output.splitlines() if line.strip()], None + + +def _run_subprocess(command: list[str], description: str) -> tuple[bool, str]: + try: + completed = subprocess.run( + command, + check=False, + capture_output=True, + text=True, + ) + except Exception as exc: + return False, f"{description} failed to start: {exc}" + + if completed.returncode == 0: + return True, completed.stdout.strip() + + details = completed.stderr.strip() or completed.stdout.strip() or "Unknown error" + return False, f"{description} failed: {details}" + + +def _ensure_pip_available(python_executable: str) -> tuple[bool, str | None]: + ok, message = _run_subprocess( + [python_executable, "-m", "pip", "--version"], + "Checking pip", + ) + if ok: + return True, None + + ok, ensure_message = _run_subprocess( + [python_executable, "-m", "ensurepip", "--upgrade"], + "Bootstrapping pip", + ) + if ok: + return True, None + return False, ensure_message or message + + +def _install_requirements_with_python(python_executable: str) -> tuple[bool, str | None]: + if not REQUIREMENTS_FILE.exists(): + return False, f"Requirements file not found: {REQUIREMENTS_FILE}" + + pip_ready, pip_message = _ensure_pip_available(python_executable) + if not pip_ready: + return False, pip_message + + print("Installing missing Python packages...", flush=True) + ok, message = _run_subprocess( + [ + python_executable, + "-m", + "pip", + "install", + "--disable-pip-version-check", + "-r", + str(REQUIREMENTS_FILE), + ], + "Installing requirements", + ) + if not ok: + return False, message + return True, None + + +def _create_project_venv() -> tuple[bool, str | None]: + venv_python = _project_venv_python() + if venv_python.exists(): + return True, None + + print(f"Creating local virtual environment in {PROJECT_VENV_DIR}...", flush=True) + ok, message = _run_subprocess( + [sys.executable, "-m", "venv", str(PROJECT_VENV_DIR)], + "Creating virtual environment", + ) + if ok and venv_python.exists(): + return True, None + + help_message = "\n".join( + [ + message or "Creating virtual environment failed.", + "", + "On Raspberry Pi / Debian, install venv support first:", + " sudo apt install python3-venv python3-full", + ] + ) + return False, help_message + + +def _restart_inside_project_venv() -> tuple[bool, str | None]: + venv_python = _project_venv_python() + if not venv_python.exists(): + return False, f"Virtual environment Python was not found: {venv_python}" + + print("Restarting app inside the local virtual environment...", flush=True) + argv = [str(venv_python), str(BASE_DIR / "main.py"), *sys.argv[1:]] + env = os.environ.copy() + env["SECURE_SMS_RUNTIME_VENV"] = str(PROJECT_VENV_DIR) + try: + os.execve(str(venv_python), argv, env) + except Exception as exc: + return False, f"Restarting inside virtual environment failed: {exc}" + + return True, None + + +def _ensure_runtime_dependencies() -> tuple[bool, str | None]: + missing = _missing_requirements() + if not missing: + return True, None + + print(f"Missing packages detected: {', '.join(missing)}", flush=True) + + if _running_inside_virtualenv(): + ok, message = _install_requirements_with_python(sys.executable) + if not ok: + return False, message + else: + venv_python = _project_venv_python() + if venv_python.exists(): + venv_missing, venv_message = _missing_requirements_for_python(str(venv_python)) + if venv_message: + return False, venv_message + if not venv_missing: + ok, message = _restart_inside_project_venv() + if not ok: + return False, message + return False, "Unexpected bootstrap state while switching to the local virtual environment." + + ok, message = _create_project_venv() + if not ok: + return False, message + + ok, message = _install_requirements_with_python(str(venv_python)) + if not ok: + return False, message + + ok, message = _restart_inside_project_venv() + if not ok: + return False, message + return False, "Unexpected bootstrap state while switching to the local virtual environment." + + remaining = _missing_requirements() + if remaining: + return False, f"Packages are still missing after installation: {', '.join(remaining)}" + + return True, None + + +def _prepare_linux_display() -> tuple[bool, str | None]: + if not sys.platform.startswith("linux"): + return True, None + + display = os.environ.get("DISPLAY") + if display: + return True, None + + x11_socket = Path("/tmp/.X11-unix/X0") + if x11_socket.exists(): + os.environ["DISPLAY"] = ":0" + return True, None + + message = "\n".join( + [ + "No GUI display was found for Tkinter.", + "This app needs a graphical desktop session on Raspberry Pi.", + "", + "If you are on the Pi screen, start the desktop first and then run the app.", + "If you run it from SSH/systemd, usually you need:", + " DISPLAY=:0", + " XAUTHORITY=/home/pars/.Xauthority", + "", + "Example:", + " export DISPLAY=:0", + " export XAUTHORITY=/home/pars/.Xauthority", + " python3 main.py", + ] + ) + return False, message + + +def _display_error_message(exc: Exception) -> str: + return "\n".join( + [ + f"GUI startup failed: {exc}", + "", + "Tkinter could not connect to the Raspberry Pi graphical session.", + "Run the app from the Pi desktop session or set DISPLAY/XAUTHORITY correctly.", + "", + "Typical Raspberry Pi values:", + " DISPLAY=:0", + " XAUTHORITY=/home/pars/.Xauthority", + ] + ) + + +def main(): + deps_ready, deps_message = _ensure_runtime_dependencies() + if not deps_ready: + print(deps_message, file=sys.stderr) + return 1 + + ready, message = _prepare_linux_display() + if not ready: + print(message, file=sys.stderr) + return 1 + + try: + from secure_sms.controller import AppController + from secure_sms.ui import SecureSmsApp + + controller = AppController() + app = SecureSmsApp(controller) + controller.bind_ui(app) + app.protocol("WM_DELETE_WINDOW", controller.shutdown) + app.mainloop() + return 0 + except TclError as exc: + print(_display_error_message(exc), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..fc4a664 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +customtkinter==5.2.2 +cryptography==42.0.5 +pyserial==3.5 +arabic-reshaper==3.0.0 +python-bidi==0.4.2 diff --git a/secure_sms/__init__.py b/secure_sms/__init__.py new file mode 100644 index 0000000..e5b80de --- /dev/null +++ b/secure_sms/__init__.py @@ -0,0 +1 @@ +"""Secure SMS application package.""" diff --git a/secure_sms/__pycache__/__init__.cpython-313.pyc b/secure_sms/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..eee4753 Binary files /dev/null and b/secure_sms/__pycache__/__init__.cpython-313.pyc differ diff --git a/secure_sms/__pycache__/controller.cpython-313.pyc b/secure_sms/__pycache__/controller.cpython-313.pyc new file mode 100644 index 0000000..d357465 Binary files /dev/null and b/secure_sms/__pycache__/controller.cpython-313.pyc differ diff --git a/secure_sms/__pycache__/database.cpython-313.pyc b/secure_sms/__pycache__/database.cpython-313.pyc new file mode 100644 index 0000000..8fe8b3b Binary files /dev/null and b/secure_sms/__pycache__/database.cpython-313.pyc differ diff --git a/secure_sms/__pycache__/gsm.cpython-313.pyc b/secure_sms/__pycache__/gsm.cpython-313.pyc new file mode 100644 index 0000000..624aad6 Binary files /dev/null and b/secure_sms/__pycache__/gsm.cpython-313.pyc differ diff --git a/secure_sms/__pycache__/models.cpython-313.pyc b/secure_sms/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000..b5b84df Binary files /dev/null and b/secure_sms/__pycache__/models.cpython-313.pyc differ diff --git a/secure_sms/__pycache__/protocol.cpython-313.pyc b/secure_sms/__pycache__/protocol.cpython-313.pyc new file mode 100644 index 0000000..efcd409 Binary files /dev/null and b/secure_sms/__pycache__/protocol.cpython-313.pyc differ diff --git a/secure_sms/__pycache__/security.cpython-313.pyc b/secure_sms/__pycache__/security.cpython-313.pyc new file mode 100644 index 0000000..051561c Binary files /dev/null and b/secure_sms/__pycache__/security.cpython-313.pyc differ diff --git a/secure_sms/__pycache__/services.cpython-313.pyc b/secure_sms/__pycache__/services.cpython-313.pyc new file mode 100644 index 0000000..97fb9ab Binary files /dev/null and b/secure_sms/__pycache__/services.cpython-313.pyc differ diff --git a/secure_sms/__pycache__/ui.cpython-313.pyc b/secure_sms/__pycache__/ui.cpython-313.pyc new file mode 100644 index 0000000..ce9c9b9 Binary files /dev/null and b/secure_sms/__pycache__/ui.cpython-313.pyc differ diff --git a/secure_sms/controller.py b/secure_sms/controller.py new file mode 100644 index 0000000..308611d --- /dev/null +++ b/secure_sms/controller.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +from typing import Optional + +from secure_sms.database import Database +from secure_sms.gsm import GSMGateway +from secure_sms.services import SecureMessagingService + + +class AppController: + def __init__(self): + self.db = Database() + self.service = SecureMessagingService(self.db) + self.gsm: Optional[GSMGateway] = None + self.ui = None + + def bind_ui(self, ui): + self.ui = ui + + def is_bootstrapped(self) -> bool: + return self.service.is_bootstrapped() + + def bootstrap(self, password: str): + self.service.bootstrap(password) + self._start_gsm() + + def unlock(self, password: str) -> bool: + unlocked = self.service.unlock(password) + if unlocked: + self._start_gsm() + return unlocked + + def verify_password(self, password: str) -> bool: + return self.service.verify_password(password) + + def change_master_password(self, current_password: str, new_password: str): + self.service.change_master_password(current_password, new_password) + + def _start_gsm(self) -> bool: + self._stop_gsm() + port, baudrate = self.service.get_connection_settings() + self.gsm = GSMGateway(port=port, baudrate=baudrate, message_callback=self._on_sms_received) + connected = self.gsm.connect() + self._notify_ui() + return connected + + def reconnect_modem(self, port: str, baudrate: int) -> bool: + self.service.update_connection_settings(port, baudrate) + return self._start_gsm() + + def modem_status(self) -> dict: + return { + "connected": bool(self.gsm and self.gsm.is_connected), + "port": self.gsm.port if self.gsm else self.service.get_connection_settings()[0], + "baudrate": self.gsm.baudrate if self.gsm else self.service.get_connection_settings()[1], + } + + def list_contacts(self): + return self.service.list_contacts() + + def get_contact(self, phone: str): + return self.service.get_contact(phone) + + def save_contact(self, name: str, phone: str): + self.service.add_or_update_contact(name, phone) + self._notify_ui() + + def get_messages(self, phone: str): + return self.service.get_messages(phone) + + def send_message(self, phone: str, text: str) -> tuple[bool, str]: + try: + frames, mode = self.service.prepare_outgoing_message(phone, text) + 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" + + 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._notify_ui(phone) + return ok, status + + 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._notify_ui(phone) + return ok, status + + def get_admin_snapshot(self) -> dict: + snapshot = self.service.get_admin_snapshot() + snapshot["modem"] = self.modem_status() + return snapshot + + def _on_sms_received(self, sender: str, raw_text: str): + try: + _, reply_frames = self.service.process_incoming_sms(sender, raw_text) + if reply_frames and self.gsm and self.gsm.is_connected: + self.gsm.send_frames(sender, reply_frames) + finally: + self._notify_ui(sender) + + def _notify_ui(self, phone: Optional[str] = None): + if self.ui: + self.ui.after(0, lambda: self.ui.handle_background_refresh(phone)) + + def _stop_gsm(self): + if self.gsm: + self.gsm.disconnect() + self.gsm = None + + def shutdown(self): + self._stop_gsm() + if self.ui: + self.ui.destroy() diff --git a/secure_sms/database.py b/secure_sms/database.py new file mode 100644 index 0000000..468807a --- /dev/null +++ b/secure_sms/database.py @@ -0,0 +1,410 @@ +import sqlite3 +from datetime import datetime +from pathlib import Path +from typing import Optional + +from secure_sms.security import SecurityMetadata, StorageCipher + + +DB_FILE = "secure_sms_v2.db" + + +def utc_now() -> str: + return datetime.utcnow().replace(microsecond=0).isoformat() + "Z" + + +class Database: + def __init__(self, db_path: str = DB_FILE): + self.db_path = Path(db_path) + self._initialize() + + def _connect(self) -> sqlite3.Connection: + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + return conn + + def _initialize(self): + with self._connect() as conn: + cursor = conn.cursor() + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS app_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + """ + ) + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS identity ( + id INTEGER PRIMARY KEY CHECK(id = 1), + private_key_enc TEXT NOT NULL, + public_key_enc TEXT NOT NULL, + fingerprint TEXT NOT NULL, + created_at TEXT NOT NULL + ) + """ + ) + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS contacts ( + phone TEXT PRIMARY KEY, + name_enc TEXT NOT NULL, + mode TEXT NOT NULL DEFAULT 'normal', + secure_state TEXT NOT NULL DEFAULT 'none', + peer_public_key_enc TEXT, + peer_fingerprint TEXT, + last_secure_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + phone TEXT NOT NULL, + direction TEXT NOT NULL, + body_enc TEXT NOT NULL, + mode TEXT NOT NULL, + transport_state TEXT NOT NULL, + metadata_enc TEXT, + created_at TEXT NOT NULL + ) + """ + ) + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS packet_fragments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + phone TEXT NOT NULL, + packet_id TEXT NOT NULL, + packet_kind TEXT NOT NULL, + packet_mode TEXT, + part_no INTEGER NOT NULL, + total_parts INTEGER NOT NULL, + chunk TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(phone, packet_id, part_no) + ) + """ + ) + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS secure_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + phone TEXT, + event_type TEXT NOT NULL, + details_enc TEXT, + created_at TEXT NOT NULL + ) + """ + ) + conn.commit() + + def is_bootstrapped(self) -> bool: + return self.get_security_metadata() is not None + + def get_security_metadata(self) -> Optional[SecurityMetadata]: + with self._connect() as conn: + cursor = conn.cursor() + cursor.execute("SELECT value FROM app_config WHERE key = 'password_salt'") + salt_row = cursor.fetchone() + cursor.execute("SELECT value FROM app_config WHERE key = 'password_verifier'") + verifier_row = cursor.fetchone() + if not salt_row or not verifier_row: + return None + return SecurityMetadata(salt=salt_row["value"], verifier=verifier_row["value"]) + + def set_security_metadata(self, meta: SecurityMetadata): + self.set_config("password_salt", meta.salt) + self.set_config("password_verifier", meta.verifier) + + def set_config(self, key: str, value: str): + with self._connect() as conn: + conn.execute( + "INSERT INTO app_config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", + (key, value), + ) + conn.commit() + + def get_config(self, key: str, default: Optional[str] = None) -> Optional[str]: + with self._connect() as conn: + cursor = conn.cursor() + cursor.execute("SELECT value FROM app_config WHERE key = ?", (key,)) + row = cursor.fetchone() + return row["value"] if row else default + + def get_connection_settings(self) -> tuple[str, int]: + port = self.get_config("gsm_port", "COM1") or "COM1" + baudrate = int(self.get_config("gsm_baudrate", "115200") or "115200") + return port, baudrate + + def set_connection_settings(self, port: str, baudrate: int): + self.set_config("gsm_port", port) + self.set_config("gsm_baudrate", str(baudrate)) + + def save_identity(self, private_key_enc: str, public_key_enc: str, fingerprint: str): + with self._connect() as conn: + conn.execute( + """ + INSERT INTO identity(id, private_key_enc, public_key_enc, fingerprint, created_at) + VALUES(1, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + private_key_enc = excluded.private_key_enc, + public_key_enc = excluded.public_key_enc, + fingerprint = excluded.fingerprint + """, + (private_key_enc, public_key_enc, fingerprint, utc_now()), + ) + conn.commit() + + def get_identity_row(self) -> Optional[sqlite3.Row]: + with self._connect() as conn: + cursor = conn.cursor() + cursor.execute("SELECT * FROM identity WHERE id = 1") + return cursor.fetchone() + + def upsert_contact(self, phone: str, name_enc: str): + now = utc_now() + with self._connect() as conn: + conn.execute( + """ + INSERT INTO contacts(phone, name_enc, mode, secure_state, created_at, updated_at) + VALUES(?, ?, 'normal', 'none', ?, ?) + ON CONFLICT(phone) DO UPDATE SET + name_enc = excluded.name_enc, + updated_at = excluded.updated_at + """, + (phone, name_enc, now, now), + ) + conn.commit() + + def ensure_contact_exists(self, phone: str, name_enc: str): + now = utc_now() + with self._connect() as conn: + conn.execute( + """ + INSERT INTO contacts(phone, name_enc, mode, secure_state, created_at, updated_at) + VALUES(?, ?, 'normal', 'none', ?, ?) + ON CONFLICT(phone) DO NOTHING + """, + (phone, name_enc, now, now), + ) + conn.commit() + + def get_contact_row(self, phone: str) -> Optional[sqlite3.Row]: + with self._connect() as conn: + cursor = conn.cursor() + cursor.execute("SELECT * FROM contacts WHERE phone = ?", (phone,)) + return cursor.fetchone() + + def list_contact_rows(self) -> list[sqlite3.Row]: + with self._connect() as conn: + cursor = conn.cursor() + cursor.execute( + """ + SELECT + c.*, + m.body_enc AS last_body_enc + FROM contacts c + LEFT JOIN messages m + ON m.id = ( + SELECT id FROM messages + WHERE phone = c.phone + ORDER BY id DESC + LIMIT 1 + ) + ORDER BY COALESCE(m.id, 0) DESC, c.updated_at DESC + """ + ) + return cursor.fetchall() + + def update_contact_security( + self, + phone: str, + *, + mode: Optional[str] = None, + secure_state: Optional[str] = None, + peer_public_key_enc: Optional[str] = None, + peer_fingerprint: Optional[str] = None, + last_secure_at: Optional[str] = None, + ): + updates = [] + values = [] + if mode is not None: + updates.append("mode = ?") + values.append(mode) + if secure_state is not None: + updates.append("secure_state = ?") + values.append(secure_state) + if peer_public_key_enc is not None: + updates.append("peer_public_key_enc = ?") + values.append(peer_public_key_enc) + if peer_fingerprint is not None: + updates.append("peer_fingerprint = ?") + values.append(peer_fingerprint) + if last_secure_at is not None: + updates.append("last_secure_at = ?") + values.append(last_secure_at) + updates.append("updated_at = ?") + values.append(utc_now()) + values.append(phone) + with self._connect() as conn: + conn.execute( + f"UPDATE contacts SET {', '.join(updates)} WHERE phone = ?", + values, + ) + conn.commit() + + def add_message( + self, + phone: str, + direction: str, + body_enc: str, + mode: str, + transport_state: str, + metadata_enc: Optional[str] = None, + ) -> int: + with self._connect() as conn: + cursor = conn.cursor() + cursor.execute( + """ + INSERT INTO messages(phone, direction, body_enc, mode, transport_state, metadata_enc, created_at) + VALUES(?, ?, ?, ?, ?, ?, ?) + """, + (phone, direction, body_enc, mode, transport_state, metadata_enc, utc_now()), + ) + conn.commit() + return int(cursor.lastrowid) + + def list_message_rows(self, phone: str) -> list[sqlite3.Row]: + with self._connect() as conn: + cursor = conn.cursor() + cursor.execute("SELECT * FROM messages WHERE phone = ? ORDER BY id ASC", (phone,)) + return cursor.fetchall() + + def log_secure_event(self, phone: Optional[str], event_type: str, details_enc: Optional[str]): + with self._connect() as conn: + conn.execute( + "INSERT INTO secure_events(phone, event_type, details_enc, created_at) VALUES(?, ?, ?, ?)", + (phone, event_type, details_enc, utc_now()), + ) + conn.commit() + + def list_secure_event_rows(self, limit: int = 50) -> list[sqlite3.Row]: + with self._connect() as conn: + cursor = conn.cursor() + cursor.execute("SELECT * FROM secure_events ORDER BY id DESC LIMIT ?", (limit,)) + return cursor.fetchall() + + def save_fragment( + self, + phone: str, + packet_id: str, + packet_kind: str, + packet_mode: Optional[str], + part_no: int, + total_parts: int, + chunk: str, + ): + with self._connect() as conn: + conn.execute( + """ + INSERT INTO packet_fragments(phone, packet_id, packet_kind, packet_mode, part_no, total_parts, chunk, created_at) + VALUES(?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(phone, packet_id, part_no) DO NOTHING + """, + (phone, packet_id, packet_kind, packet_mode, part_no, total_parts, chunk, utc_now()), + ) + conn.commit() + + def get_packet_fragments(self, phone: str, packet_id: str) -> list[sqlite3.Row]: + with self._connect() as conn: + cursor = conn.cursor() + cursor.execute( + """ + SELECT * FROM packet_fragments + WHERE phone = ? AND packet_id = ? + ORDER BY part_no ASC + """, + (phone, packet_id), + ) + return cursor.fetchall() + + def delete_packet_fragments(self, phone: str, packet_id: str): + with self._connect() as conn: + conn.execute("DELETE FROM packet_fragments WHERE phone = ? AND packet_id = ?", (phone, packet_id)) + conn.commit() + + def list_pending_packets(self) -> list[sqlite3.Row]: + with self._connect() as conn: + cursor = conn.cursor() + cursor.execute( + """ + SELECT + phone, + packet_id, + packet_kind, + packet_mode, + COUNT(*) AS received_parts, + MAX(total_parts) AS total_parts, + MIN(created_at) AS first_seen + FROM packet_fragments + GROUP BY phone, packet_id, packet_kind, packet_mode + ORDER BY MIN(created_at) DESC + """ + ) + return cursor.fetchall() + + def collect_stats(self) -> dict: + with self._connect() as conn: + cursor = conn.cursor() + stats = {} + cursor.execute("SELECT COUNT(*) AS count FROM contacts") + stats["contacts"] = cursor.fetchone()["count"] + cursor.execute("SELECT COUNT(*) AS count FROM contacts WHERE mode = 'secure'") + stats["secure_contacts"] = cursor.fetchone()["count"] + cursor.execute("SELECT COUNT(*) AS count FROM contacts WHERE secure_state = 'pending'") + stats["pending_contacts"] = cursor.fetchone()["count"] + cursor.execute("SELECT COUNT(*) AS count FROM messages") + stats["messages"] = cursor.fetchone()["count"] + cursor.execute("SELECT COUNT(*) AS count FROM messages WHERE mode = 'secure'") + stats["secure_messages"] = cursor.fetchone()["count"] + cursor.execute("SELECT COUNT(DISTINCT packet_id) AS count FROM packet_fragments") + stats["incomplete_packets"] = cursor.fetchone()["count"] + cursor.execute("SELECT COUNT(*) AS count FROM secure_events WHERE event_type = 'secure_established'") + stats["secure_connections"] = cursor.fetchone()["count"] + return stats + + def rotate_encrypted_payloads(self, old_cipher: StorageCipher, new_cipher: StorageCipher): + table_map = { + "contacts": ("phone", ["name_enc", "peer_public_key_enc"]), + "messages": ("id", ["body_enc", "metadata_enc"]), + "secure_events": ("id", ["details_enc"]), + "identity": ("id", ["private_key_enc", "public_key_enc"]), + } + with self._connect() as conn: + cursor = conn.cursor() + for table_name, (pk_column, encrypted_columns) in table_map.items(): + cursor.execute(f"SELECT * FROM {table_name}") + rows = cursor.fetchall() + for row in rows: + assignments = [] + values = [] + for column in encrypted_columns: + current_value = row[column] + if current_value is None: + continue + decrypted = old_cipher.decrypt_text(current_value) + assignments.append(f"{column} = ?") + values.append(new_cipher.encrypt_text(decrypted)) + if not assignments: + continue + values.append(row[pk_column]) + cursor.execute( + f"UPDATE {table_name} SET {', '.join(assignments)} WHERE {pk_column} = ?", + values, + ) + conn.commit() diff --git a/secure_sms/gsm.py b/secure_sms/gsm.py new file mode 100644 index 0000000..00045f9 --- /dev/null +++ b/secure_sms/gsm.py @@ -0,0 +1,154 @@ +import re +import threading +import time +from typing import Callable, Optional + +import serial + + +class GSMGateway: + def __init__( + self, + port: str, + baudrate: int, + message_callback: Optional[Callable[[str, str], None]] = None, + ): + self.port = port + self.baudrate = baudrate + self.message_callback = message_callback + self.serial_conn = None + self.is_running = False + self.read_thread = None + self.lock = threading.Lock() + + @property + def is_connected(self) -> bool: + return bool(self.serial_conn and self.serial_conn.is_open) + + def connect(self) -> bool: + try: + self.serial_conn = serial.Serial( + port=self.port, + baudrate=self.baudrate, + timeout=1, + ) + self.is_running = True + self.send_at_cmd("AT") + self.send_at_cmd("AT+CMGF=1") + self.send_at_cmd('AT+CNMI=2,1,0,0,0') + self.read_thread = threading.Thread(target=self._read_loop, daemon=True) + self.read_thread.start() + return True + except Exception: + self.serial_conn = None + self.is_running = False + 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=1.5) + + def send_at_cmd(self, command: str, expected_response: str = "OK", timeout: float = 2.0) -> tuple[bool, list[str]]: + with self.lock: + if not self.is_connected: + return False, ["offline"] + self.serial_conn.reset_input_buffer() + self.serial_conn.write((command + "\r\n").encode("ascii")) + start = time.time() + lines = [] + while time.time() - start < timeout: + if self.serial_conn.in_waiting: + line = self.serial_conn.readline().decode("ascii", errors="ignore").strip() + if line: + lines.append(line) + if expected_response in line or "ERROR" in line: + break + else: + time.sleep(0.05) + return expected_response in "\n".join(lines), lines + + def send_frames(self, phone: str, frames: list[str]) -> bool: + if not self.is_connected: + return False + for frame in frames: + if not self._send_single_sms(phone, frame): + return False + time.sleep(0.8) + return True + + def _send_single_sms(self, phone: str, body: str) -> bool: + with self.lock: + try: + self.serial_conn.reset_input_buffer() + self.serial_conn.write(f'AT+CMGS="{phone}"\r\n'.encode("ascii")) + start = time.time() + prompt_ready = False + while time.time() - start < 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: + self.serial_conn.write(chr(27).encode("ascii")) + return False + self.serial_conn.write(body.encode("ascii") + chr(26).encode("ascii")) + start = time.time() + while time.time() - start < 10: + if self.serial_conn.in_waiting: + line = self.serial_conn.readline().decode("ascii", errors="ignore").strip() + if "OK" in line: + return True + if "ERROR" in line: + return False + else: + time.sleep(0.1) + except Exception: + return False + return False + + def _read_loop(self): + while self.is_running: + try: + if self.is_connected and self.serial_conn.in_waiting and self.lock.acquire(blocking=False): + try: + line = self.serial_conn.readline().decode("ascii", errors="ignore").strip() + if line.startswith("+CMTI:"): + match = re.search(r'\+CMTI:\s*".*?",(\d+)', line) + if match: + index = int(match.group(1)) + threading.Thread( + target=self._process_incoming_sms, + args=(index,), + daemon=True, + ).start() + finally: + self.lock.release() + except Exception: + pass + time.sleep(0.1) + + def _process_incoming_sms(self, index: int): + time.sleep(0.7) + ok, lines = self.send_at_cmd(f"AT+CMGR={index}", expected_response="OK", timeout=3) + if not ok: + return + sender = "ناشناس" + body_lines = [] + reading_body = False + for line in lines: + if line.startswith("+CMGR:"): + parts = line.split(",") + if len(parts) >= 2: + sender = parts[1].strip('"') + reading_body = True + continue + if reading_body and line not in {"OK", "ERROR"}: + body_lines.append(line) + if self.message_callback: + self.message_callback(sender, "\n".join(body_lines)) + self.send_at_cmd(f"AT+CMGD={index}") diff --git a/secure_sms/models.py b/secure_sms/models.py new file mode 100644 index 0000000..dc07b0e --- /dev/null +++ b/secure_sms/models.py @@ -0,0 +1,53 @@ +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class ContactSummary: + phone: str + name: str + mode: str + secure_state: str + has_peer_key: bool + last_message_preview: str = "" + + +@dataclass +class ContactDetails: + phone: str + name: str + mode: str + secure_state: str + peer_fingerprint: Optional[str] + has_peer_key: bool + last_secure_at: Optional[str] + + +@dataclass +class MessageView: + id: int + phone: str + direction: str + body: str + mode: str + transport_state: str + created_at: str + + +@dataclass +class SecureEventView: + created_at: str + event_type: str + phone: str + details: str + + +@dataclass +class PendingPacketView: + phone: str + packet_id: str + packet_kind: str + packet_mode: Optional[str] + received_parts: int + total_parts: int + first_seen: str diff --git a/secure_sms/protocol.py b/secure_sms/protocol.py new file mode 100644 index 0000000..1d37180 --- /dev/null +++ b/secure_sms/protocol.py @@ -0,0 +1,94 @@ +import json +import uuid +from dataclasses import dataclass +from typing import Optional + +from secure_sms.security import b64u_decode, b64u_encode + + +FRAME_PREFIX = "@SSM1" +FRAME_CHUNK_SIZE = 92 + + +@dataclass +class ParsedFrame: + category: str + packet_id: str + part_no: int + total_parts: int + chunk: str + mode: Optional[str] = None + + +def _split_payload(encoded_payload: str) -> list[str]: + return [ + encoded_payload[index:index + FRAME_CHUNK_SIZE] + for index in range(0, len(encoded_payload), FRAME_CHUNK_SIZE) + ] or [""] + + +def encode_plain_body(text: str) -> str: + return b64u_encode(text.encode("utf-8")) + + +def decode_plain_body(encoded_text: str) -> str: + return b64u_decode(encoded_text).decode("utf-8") + + +def encode_control_payload(payload: dict) -> str: + packed = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + return b64u_encode(packed) + + +def decode_control_payload(encoded_payload: str) -> dict: + return json.loads(b64u_decode(encoded_payload).decode("utf-8")) + + +def build_control_frames(payload: dict, packet_id: Optional[str] = None) -> list[str]: + packet_id = packet_id or uuid.uuid4().hex[:10] + encoded_payload = encode_control_payload(payload) + parts = _split_payload(encoded_payload) + return [ + f"{FRAME_PREFIX}|CTL|{packet_id}|{index + 1}|{len(parts)}|{chunk}" + for index, chunk in enumerate(parts) + ] + + +def build_message_frames(mode: str, encoded_payload: str, packet_id: Optional[str] = None) -> list[str]: + packet_id = packet_id or uuid.uuid4().hex[:10] + parts = _split_payload(encoded_payload) + return [ + f"{FRAME_PREFIX}|MSG|{mode}|{packet_id}|{index + 1}|{len(parts)}|{chunk}" + for index, chunk in enumerate(parts) + ] + + +def parse_frame(raw_text: str) -> Optional[ParsedFrame]: + if not raw_text.startswith(FRAME_PREFIX): + return None + if raw_text.startswith(f"{FRAME_PREFIX}|CTL|"): + parts = raw_text.split("|", 5) + if len(parts) != 6: + return None + _, _, packet_id, part_no, total_parts, chunk = parts + return ParsedFrame( + category="control", + packet_id=packet_id, + part_no=int(part_no), + total_parts=int(total_parts), + chunk=chunk, + ) + if raw_text.startswith(f"{FRAME_PREFIX}|MSG|"): + parts = raw_text.split("|", 6) + if len(parts) != 7: + return None + _, _, mode, packet_id, part_no, total_parts, chunk = parts + return ParsedFrame( + category="message", + mode=mode, + packet_id=packet_id, + part_no=int(part_no), + total_parts=int(total_parts), + chunk=chunk, + ) + return None diff --git a/secure_sms/security.py b/secure_sms/security.py new file mode 100644 index 0000000..e90ac55 --- /dev/null +++ b/secure_sms/security.py @@ -0,0 +1,133 @@ +import base64 +import hashlib +import os +from dataclasses import dataclass +from typing import Optional + +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import x25519 +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from cryptography.hazmat.primitives.kdf.scrypt import Scrypt + + +def b64u_encode(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=") + + +def b64u_decode(value: str) -> bytes: + padding = "=" * (-len(value) % 4) + return base64.urlsafe_b64decode((value + padding).encode("ascii")) + + +@dataclass +class SecurityMetadata: + salt: str + verifier: str + + +class PasswordManager: + def create_metadata(self, password: str) -> SecurityMetadata: + salt = os.urandom(16) + key = self.derive_key(password, b64u_encode(salt)) + return SecurityMetadata( + salt=b64u_encode(salt), + verifier=hashlib.sha256(key).hexdigest(), + ) + + def derive_key(self, password: str, salt_b64: str) -> bytes: + kdf = Scrypt( + salt=b64u_decode(salt_b64), + length=32, + n=2**14, + r=8, + p=1, + ) + return kdf.derive(password.encode("utf-8")) + + def verify_password(self, password: str, meta: SecurityMetadata) -> bool: + key = self.derive_key(password, meta.salt) + return hashlib.sha256(key).hexdigest() == meta.verifier + + +class StorageCipher: + def __init__(self, key: bytes): + self._aes = AESGCM(key) + + def encrypt_text(self, value: Optional[str]) -> Optional[str]: + if value is None: + return None + nonce = os.urandom(12) + payload = self._aes.encrypt(nonce, value.encode("utf-8"), None) + return "enc1:" + b64u_encode(nonce + payload) + + def decrypt_text(self, value: Optional[str]) -> Optional[str]: + if value in (None, ""): + return value + if not value.startswith("enc1:"): + return value + raw = b64u_decode(value[5:]) + nonce = raw[:12] + ciphertext = raw[12:] + plaintext = self._aes.decrypt(nonce, ciphertext, None) + return plaintext.decode("utf-8") + + +class ECCCryptoService: + INFO = b"sms-secure-channel-v2" + + def generate_identity(self) -> tuple[str, str, str]: + private_key = x25519.X25519PrivateKey.generate() + public_key = private_key.public_key() + private_raw = private_key.private_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PrivateFormat.Raw, + encryption_algorithm=serialization.NoEncryption(), + ) + public_raw = public_key.public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + public_b64 = b64u_encode(public_raw) + return b64u_encode(private_raw), public_b64, self.fingerprint_public_key(public_b64) + + def fingerprint_public_key(self, public_key_b64: str) -> str: + digest = hashlib.sha256(b64u_decode(public_key_b64)).hexdigest() + return digest[:16].upper() + + def encrypt_for_peer(self, message: str, peer_public_key_b64: str) -> str: + peer_public = x25519.X25519PublicKey.from_public_bytes(b64u_decode(peer_public_key_b64)) + ephemeral_private = x25519.X25519PrivateKey.generate() + ephemeral_public = ephemeral_private.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + shared_key = ephemeral_private.exchange(peer_public) + derived_key = HKDF( + algorithm=hashes.SHA256(), + length=32, + salt=None, + info=self.INFO, + ).derive(shared_key) + nonce = os.urandom(12) + ciphertext = AESGCM(derived_key).encrypt(nonce, message.encode("utf-8"), None) + return b64u_encode(ephemeral_public + nonce + ciphertext) + + def decrypt_from_peer(self, payload_b64: str, private_key_b64: str) -> str: + payload = b64u_decode(payload_b64) + if len(payload) < 60: + raise ValueError("Secure payload is too short.") + ephemeral_public_raw = payload[:32] + nonce = payload[32:44] + ciphertext = payload[44:] + private_key = x25519.X25519PrivateKey.from_private_bytes(b64u_decode(private_key_b64)) + ephemeral_public = x25519.X25519PublicKey.from_public_bytes(ephemeral_public_raw) + shared_key = private_key.exchange(ephemeral_public) + derived_key = HKDF( + algorithm=hashes.SHA256(), + length=32, + salt=None, + info=self.INFO, + ).derive(shared_key) + plaintext = AESGCM(derived_key).decrypt(nonce, ciphertext, None) + return plaintext.decode("utf-8") diff --git a/secure_sms/services.py b/secure_sms/services.py new file mode 100644 index 0000000..7abc8a0 --- /dev/null +++ b/secure_sms/services.py @@ -0,0 +1,407 @@ +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 ( + build_control_frames, + build_message_frames, + decode_control_payload, + decode_plain_body, + encode_plain_body, + parse_frame, +) +from secure_sms.security import ECCCryptoService, PasswordManager, StorageCipher + + +SYSTEM_CONTACT_LABEL = "مخاطب ناشناس" + + +class SecureMessagingService: + def __init__(self, db: Database): + self.db = db + self.password_manager = PasswordManager() + self.crypto = ECCCryptoService() + self.cipher: Optional[StorageCipher] = None + self.identity = None + + @property + def unlocked(self) -> bool: + return self.cipher is not None and self.identity is not None + + def is_bootstrapped(self) -> bool: + return self.db.is_bootstrapped() + + def bootstrap(self, password: str): + if self.db.is_bootstrapped(): + raise ValueError("Application is already configured.") + meta = self.password_manager.create_metadata(password) + key = self.password_manager.derive_key(password, meta.salt) + self.cipher = StorageCipher(key) + private_key, public_key, fingerprint = self.crypto.generate_identity() + self.db.set_security_metadata(meta) + self.db.save_identity( + private_key_enc=self.cipher.encrypt_text(private_key), + public_key_enc=self.cipher.encrypt_text(public_key), + fingerprint=fingerprint, + ) + self.db.set_connection_settings("COM1", 115200) + self.identity = { + "private_key": private_key, + "public_key": public_key, + "fingerprint": fingerprint, + } + self.db.log_secure_event(None, "app_bootstrap", self._enc("راه‌اندازی اولیه برنامه انجام شد.")) + + def unlock(self, password: str) -> bool: + meta = self.db.get_security_metadata() + if not meta: + raise ValueError("Application is not configured.") + if not self.password_manager.verify_password(password, meta): + return False + key = self.password_manager.derive_key(password, meta.salt) + self.cipher = StorageCipher(key) + identity_row = self.db.get_identity_row() + if identity_row is None: + raise ValueError("Secure identity was not found.") + self.identity = { + "private_key": self.cipher.decrypt_text(identity_row["private_key_enc"]), + "public_key": self.cipher.decrypt_text(identity_row["public_key_enc"]), + "fingerprint": identity_row["fingerprint"], + } + return True + + def verify_password(self, password: str) -> bool: + meta = self.db.get_security_metadata() + if not meta: + return False + return self.password_manager.verify_password(password, meta) + + def change_master_password(self, current_password: str, new_password: str): + meta = self.db.get_security_metadata() + if not meta or not self.password_manager.verify_password(current_password, meta): + raise ValueError("رمز فعلی درست نیست.") + old_key = self.password_manager.derive_key(current_password, meta.salt) + new_meta = self.password_manager.create_metadata(new_password) + new_key = self.password_manager.derive_key(new_password, new_meta.salt) + old_cipher = StorageCipher(old_key) + new_cipher = StorageCipher(new_key) + self.db.rotate_encrypted_payloads(old_cipher, new_cipher) + self.db.set_security_metadata(new_meta) + self.cipher = new_cipher + identity_row = self.db.get_identity_row() + self.identity = { + "private_key": self.cipher.decrypt_text(identity_row["private_key_enc"]), + "public_key": self.cipher.decrypt_text(identity_row["public_key_enc"]), + "fingerprint": identity_row["fingerprint"], + } + self.db.log_secure_event(None, "password_changed", self._enc("رمز اصلی برنامه تغییر کرد.")) + + def _enc(self, value: Optional[str]) -> Optional[str]: + if self.cipher is None: + raise RuntimeError("Application is locked.") + return self.cipher.encrypt_text(value) + + def _dec(self, value: Optional[str]) -> Optional[str]: + if self.cipher is None: + raise RuntimeError("Application is locked.") + return self.cipher.decrypt_text(value) + + def add_or_update_contact(self, name: str, phone: str): + self.db.upsert_contact(phone, self._enc(name)) + + def ensure_contact(self, phone: str, fallback_name: Optional[str] = None): + fallback = fallback_name or SYSTEM_CONTACT_LABEL + self.db.ensure_contact_exists(phone, self._enc(fallback)) + + def list_contacts(self) -> list[ContactSummary]: + contacts = [] + for row in self.db.list_contact_rows(): + preview = self._dec(row["last_body_enc"]) if row["last_body_enc"] else "" + if preview: + preview = preview.replace("\n", " ").strip() + contacts.append( + ContactSummary( + phone=row["phone"], + name=self._dec(row["name_enc"]) or SYSTEM_CONTACT_LABEL, + mode=row["mode"], + secure_state=row["secure_state"], + has_peer_key=bool(row["peer_public_key_enc"]), + last_message_preview=(preview[:38] + "...") if preview and len(preview) > 38 else (preview or ""), + ) + ) + return contacts + + def get_contact(self, phone: str) -> Optional[ContactDetails]: + row = self.db.get_contact_row(phone) + if not row: + return None + return ContactDetails( + phone=row["phone"], + name=self._dec(row["name_enc"]) or SYSTEM_CONTACT_LABEL, + mode=row["mode"], + secure_state=row["secure_state"], + peer_fingerprint=row["peer_fingerprint"], + has_peer_key=bool(row["peer_public_key_enc"]), + last_secure_at=row["last_secure_at"], + ) + + def get_messages(self, phone: str) -> list[MessageView]: + return [ + MessageView( + id=row["id"], + phone=row["phone"], + direction=row["direction"], + body=self._dec(row["body_enc"]) or "", + mode=row["mode"], + transport_state=row["transport_state"], + created_at=row["created_at"], + ) + for row in self.db.list_message_rows(phone) + ] + + def get_public_identity(self) -> dict: + if not self.identity: + raise RuntimeError("Application is locked.") + return { + "public_key": self.identity["public_key"], + "fingerprint": self.identity["fingerprint"], + } + + def prepare_outgoing_message(self, phone: str, text: str) -> tuple[list[str], str]: + contact = self.db.get_contact_row(phone) + if not contact: + raise ValueError("مخاطب پیدا نشد.") + mode = contact["mode"] + if mode == "secure": + peer_key = self._dec(contact["peer_public_key_enc"]) + if not peer_key: + raise ValueError("برای این مخاطب کلید امن وجود ندارد.") + encoded_payload = self.crypto.encrypt_for_peer(text, peer_key) + return build_message_frames("S", encoded_payload), "secure" + 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( + phone=phone, + direction="out", + body_enc=self._enc(text), + mode=mode, + transport_state=transport_state, + ) + + def request_secure_channel(self, phone: str) -> list[str]: + self.ensure_contact(phone) + payload = { + "type": "hello", + "public_key": self.identity["public_key"], + "fingerprint": self.identity["fingerprint"], + "ts": utc_now(), + } + self.db.update_contact_security(phone, mode="normal", secure_state="pending") + self.db.log_secure_event(phone, "hello_sent", self._enc("درخواست ارتباط امن ارسال شد.")) + self.db.add_message( + phone=phone, + direction="system", + body_enc=self._enc("درخواست ارتباط امن برای مخاطب ارسال شد."), + mode="system", + transport_state="local", + ) + return build_control_frames(payload) + + def request_normal_mode(self, phone: str) -> list[str]: + self.ensure_contact(phone) + payload = { + "type": "normal_mode", + "ts": utc_now(), + } + self.db.update_contact_security(phone, mode="normal", secure_state="ready") + self.db.log_secure_event(phone, "normal_mode_sent", self._enc("بازگشت به حالت عادی برای مخاطب ارسال شد.")) + self.db.add_message( + phone=phone, + direction="system", + body_enc=self._enc("گفتگو به حالت عادی برگشت."), + mode="system", + transport_state="local", + ) + return build_control_frames(payload) + + def process_incoming_sms(self, sender: str, raw_text: str) -> tuple[str, Optional[list[str]]]: + self.ensure_contact(sender) + frame = parse_frame(raw_text) + if not frame: + self.db.add_message( + phone=sender, + direction="in", + body_enc=self._enc(raw_text), + mode="normal", + transport_state="received_raw", + ) + return sender, None + + payload = self._store_or_assemble_frame(sender, frame) + if payload is None: + self.db.log_secure_event( + sender, + "packet_fragment_received", + self._enc(f"بسته {frame.packet_id} در حال تکمیل است ({frame.part_no}/{frame.total_parts})."), + ) + return sender, None + + if frame.category == "control": + return sender, self._handle_control_payload(sender, payload) + else: + self._handle_message_payload(sender, frame.mode or "N", payload) + return sender, None + + def _store_or_assemble_frame(self, sender: str, frame) -> Optional[str]: + if frame.total_parts == 1: + return frame.chunk + self.db.save_fragment( + sender, + frame.packet_id, + frame.category, + frame.mode, + frame.part_no, + frame.total_parts, + frame.chunk, + ) + fragments = self.db.get_packet_fragments(sender, frame.packet_id) + if len(fragments) < frame.total_parts: + return None + payload = "".join(fragment["chunk"] for fragment in fragments) + self.db.delete_packet_fragments(sender, frame.packet_id) + return payload + + def _handle_control_payload(self, sender: str, payload: str) -> Optional[list[str]]: + data = decode_control_payload(payload) + action = data.get("type") + if action == "hello": + public_key = data.get("public_key") + fingerprint = data.get("fingerprint") or self.crypto.fingerprint_public_key(public_key) + self.db.update_contact_security( + sender, + mode="secure", + secure_state="ready", + peer_public_key_enc=self._enc(public_key), + peer_fingerprint=fingerprint, + last_secure_at=utc_now(), + ) + self.db.log_secure_event(sender, "hello_received", self._enc("درخواست ارتباط امن دریافت شد.")) + self.db.log_secure_event(sender, "secure_established", self._enc("ارتباط امن برقرار شد.")) + self.db.add_message( + phone=sender, + direction="system", + body_enc=self._enc("ارتباط امن با این مخاطب فعال شد."), + mode="system", + transport_state="local", + ) + reply = { + "type": "hello_ack", + "public_key": self.identity["public_key"], + "fingerprint": self.identity["fingerprint"], + "ts": utc_now(), + } + return build_control_frames(reply) + elif action == "hello_ack": + public_key = data.get("public_key") + fingerprint = data.get("fingerprint") or self.crypto.fingerprint_public_key(public_key) + self.db.update_contact_security( + sender, + mode="secure", + secure_state="ready", + peer_public_key_enc=self._enc(public_key), + peer_fingerprint=fingerprint, + last_secure_at=utc_now(), + ) + self.db.log_secure_event(sender, "hello_ack_received", self._enc("پاسخ ارتباط امن دریافت شد.")) + self.db.log_secure_event(sender, "secure_established", self._enc("ارتباط امن برقرار شد.")) + self.db.add_message( + phone=sender, + direction="system", + body_enc=self._enc("ارتباط امن آماده استفاده است."), + mode="system", + transport_state="local", + ) + return None + elif action == "normal_mode": + self.db.update_contact_security(sender, mode="normal", secure_state="ready") + self.db.log_secure_event(sender, "normal_mode_received", self._enc("مخاطب گفتگو را به حالت عادی برگرداند.")) + self.db.add_message( + phone=sender, + direction="system", + body_enc=self._enc("مخاطب گفتگو را به حالت عادی برگرداند."), + mode="system", + transport_state="local", + ) + return None + return None + + def _handle_message_payload(self, sender: str, mode_marker: str, payload: str): + if mode_marker == "S": + try: + body = self.crypto.decrypt_from_peer(payload, self.identity["private_key"]) + mode = "secure" + transport_state = "received_secure" + except Exception: + body = "پیام امن دریافت شد اما بازگشایی نشد." + mode = "secure" + transport_state = "decrypt_failed" + self.db.log_secure_event(sender, "decrypt_failed", self._enc("بازگشایی پیام امن ناموفق بود.")) + else: + body = decode_plain_body(payload) + mode = "normal" + transport_state = "received" + + self.db.add_message( + phone=sender, + direction="in", + body_enc=self._enc(body), + mode=mode, + transport_state=transport_state, + ) + + def get_admin_snapshot(self) -> dict: + stats = self.db.collect_stats() + identity = self.get_public_identity() + return { + "stats": stats, + "events": [ + SecureEventView( + created_at=row["created_at"], + event_type=row["event_type"], + phone=row["phone"] or "-", + details=self._dec(row["details_enc"]) or "", + ) + for row in self.db.list_secure_event_rows() + ], + "pending_packets": [ + PendingPacketView( + phone=row["phone"], + packet_id=row["packet_id"], + packet_kind=row["packet_kind"], + packet_mode=row["packet_mode"], + received_parts=row["received_parts"], + total_parts=row["total_parts"], + first_seen=row["first_seen"], + ) + for row in self.db.list_pending_packets() + ], + "system_info": { + "platform": platform.platform(), + "python": platform.python_version(), + "db_path": str(Path(self.db.db_path).resolve()), + "modem_port": self.db.get_connection_settings()[0], + "baudrate": self.db.get_connection_settings()[1], + "fingerprint": identity["fingerprint"], + }, + } + + def get_connection_settings(self) -> tuple[str, int]: + return self.db.get_connection_settings() + + def update_connection_settings(self, port: str, baudrate: int): + self.db.set_connection_settings(port, baudrate) + self.db.log_secure_event(None, "connection_settings_changed", self._enc(f"تنظیمات مودم به {port}/{baudrate} تغییر کرد.")) diff --git a/secure_sms/ui.py b/secure_sms/ui.py new file mode 100644 index 0000000..52a016d --- /dev/null +++ b/secure_sms/ui.py @@ -0,0 +1,1509 @@ +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]") +KEYBOARD_LAYOUTS = { + "fa": [ + ['۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹', '۰'], + ['ض', 'ص', 'ث', 'ق', 'ف', 'غ', 'ع', 'ه', 'خ', 'ح', 'ج', 'چ'], + ['ش', 'س', 'ی', 'ب', 'ل', 'ا', 'ت', 'ن', 'م', 'ک', 'گ'], + ['ظ', 'ط', 'ز', 'ر', 'ذ', 'د', 'پ', 'و', '⌫'], + ['123', 'انگلیسی', '،', 'فاصله', '.', 'تایید'], + ], + "en": [ + ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'], + ['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'], + ['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'], + ['z', 'x', 'c', 'v', 'b', 'n', 'm', '⌫'], + ['123', 'فارسی', ',', 'فاصله', '.', 'تایید'], + ], + "numeric": [ + ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'], + ['+', '-', '/', '@', '_', '.', ':', '(', ')', '?'], + ['فارسی', 'انگلیسی', '⌫'], + ['بستن', 'فاصله', 'تایید'], + ], +} +KEYBOARD_ACTIONS = [ + ('فارسی', "fa"), + ('انگلیسی', "en"), + ("123", "numeric"), + ('پاک', "backspace"), + ('بستن', "close"), +] + + +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)) + + +class SecureSmsApp(ctk.CTk): + def __init__(self, controller): + super().__init__() + self.controller = controller + self.current_contact_phone = None + self.active_input = None + self.current_keyboard_layout = "fa" + self._keyboard_interaction_guard = False + self._text_inputs = {} + self._input_aliases = {} + self.kiosk_mode = os.environ.get("SECURE_SMS_WINDOWED", "0") != "1" + self.screen_width = max(480, self.winfo_screenwidth()) + self.screen_height = max(320, self.winfo_screenheight()) + self.is_portrait = self.screen_height > self.screen_width + self.compact_mode = self.is_portrait or self.screen_width <= 560 + if self.kiosk_mode: + self.window_width = self.screen_width + self.window_height = self.screen_height + else: + self.window_width, self.window_height = (480, 800) if self.is_portrait else (800, 480) + self.title(ui_text("پیام‌رسان امن صبا")) + self.geometry(f"{self.window_width}x{self.window_height}") + self.minsize(self.window_width, self.window_height) + self.resizable(False, False) + self.configure(fg_color=BACKGROUND) + self.option_add("*Cursor", "none") + self._build_shell() + self.bind_all("", self._handle_global_tap, add="+") + self._show_lock_screen() + self.after(50, self._enable_touch_kiosk_mode) + + def _enable_touch_kiosk_mode(self): + self._hide_mouse_cursor() + if not self.kiosk_mode: + return + try: + self.attributes("-topmost", True) + self.attributes("-fullscreen", True) + except TclError: + self.geometry(f"{self.window_width}x{self.window_height}+0+0") + + def _hide_mouse_cursor(self, widget=None): + current = widget or self + try: + current.configure(cursor="none") + except Exception: + pass + for child in current.winfo_children(): + self._hide_mouse_cursor(child) + + def _input_target(self, widget): + return getattr(widget, "_entry", getattr(widget, "_textbox", widget)) + + def _resolve_registered_input(self, widget): + if widget in self._text_inputs and widget.winfo_exists(): + return widget + resolved = self._input_aliases.get(str(widget)) + if resolved is not None and resolved.winfo_exists(): + return resolved + current = getattr(widget, "master", None) + while current is not None: + if current in self._text_inputs and current.winfo_exists(): + return current + resolved = self._input_aliases.get(str(current)) + if resolved is not None and resolved.winfo_exists(): + return resolved + current = getattr(current, "master", None) + return None + + def _widget_is_descendant(self, widget, ancestor): + current = widget + while current is not None: + if current == ancestor: + return True + current = getattr(current, "master", None) + return False + + def _point_inside_widget(self, widget, x_root, y_root): + if widget is None or not widget.winfo_exists() or not widget.winfo_ismapped(): + return False + left = widget.winfo_rootx() + top = widget.winfo_rooty() + right = left + widget.winfo_width() + bottom = top + widget.winfo_height() + return left <= x_root <= right and top <= y_root <= bottom + + def _register_text_input(self, widget, *, title, layout="fa", multiline=False, submit=None): + self._text_inputs[widget] = { + "title": title, + "layout": layout, + "multiline": multiline, + "submit": submit, + } + self._input_aliases[str(widget)] = widget + bind_target = self._input_target(widget) + self._input_aliases[str(bind_target)] = widget + bind_target.bind("", lambda _event, target=widget: self._activate_text_input(target), add="+") + bind_target.bind("", lambda _event, target=widget: self.after(10, lambda: self._activate_text_input(target)), add="+") + + def _focus_registered_input(self, widget): + if widget not in self._text_inputs or not widget.winfo_exists(): + return + self._input_target(widget).focus_set() + self._activate_text_input(widget) + + def _activate_text_input(self, widget): + if widget not in self._text_inputs or not widget.winfo_exists(): + return + self.active_input = widget + self._show_virtual_keyboard(self._text_inputs[widget]["layout"]) + + def _handle_global_tap(self, event): + widget = event.widget + if self._keyboard_interaction_guard: + return + if self._resolve_registered_input(widget) is not None: + return + if self._widget_is_descendant(widget, self.keyboard_host): + return + if self._point_inside_widget(self.keyboard_frame, event.x_root, event.y_root): + return + self.after(20, self._hide_virtual_keyboard) + + def _register_keyboard_interaction(self): + self._keyboard_interaction_guard = True + self.after(160, self._clear_keyboard_interaction_guard) + + def _clear_keyboard_interaction_guard(self): + self._keyboard_interaction_guard = False + + def _shift_layout_for_keyboard(self, show: bool): + if not hasattr(self, 'main_panel') or not self.active_input: + return + in_main_panel = self._widget_is_descendant(self.active_input, self.main_panel) + if show and in_main_panel: + if self.is_portrait and hasattr(self, 'sidebar') and self.sidebar.winfo_ismapped(): + self.sidebar.grid_remove() + elif not self.is_portrait and hasattr(self, 'header_card') and self.header_card.winfo_ismapped(): + self.header_card.grid_remove() + elif not show: + if self.is_portrait and hasattr(self, 'sidebar') and not self.sidebar.winfo_ismapped(): + self.sidebar.grid() + elif not self.is_portrait and hasattr(self, 'header_card') and not self.header_card.winfo_ismapped(): + self.header_card.grid() + + def _update_keyboard_preview(self): + widget = self.active_input + if widget is None or not widget.winfo_exists(): + return + target = self._input_target(widget) + try: + if isinstance(widget, ctk.CTkTextbox): + text = target.get("1.0", "end-1c") + else: + text = target.get() + text = text.replace('\n', ' ') + if len(text) > 40: + text = "..." + text[-37:] + self.keyboard_preview.configure(text=text + " |") + except Exception: + pass + + def _show_virtual_keyboard(self, layout=None): + if layout: + self.current_keyboard_layout = layout + self.keyboard_hint.configure(text=self._keyboard_title()) + self._refresh_keyboard_action_bar() + self._update_keyboard_preview() + self._shift_layout_for_keyboard(True) + if not self.keyboard_host.winfo_ismapped(): + self.keyboard_host.grid() + self._render_keyboard(self.current_keyboard_layout) + self.after(0, self._hide_mouse_cursor) + + def _hide_virtual_keyboard(self): + self.active_input = None + self._shift_layout_for_keyboard(False) + self.keyboard_host.grid_remove() + + def _keyboard_title(self): + if self.active_input in self._text_inputs: + return self._text_inputs[self.active_input]["title"] + return "ورودی" + + def _keyboard_weight(self, key): + return { + 'فاصله': 50, + "Space": 50, + 'تایید': 20, + "Enter": 20, + 'بستن': 15, + 'حذف': 15, + "Back": 15, + '⌫': 15, + 'فارسی': 15, + "English": 15, + 'انگلیسی': 15, + "123": 15, + }.get(key, 10) + + def _keyboard_style(self, key): + if key in {'تایید', "Enter"}: + return PRIMARY, PRIMARY_DARK, "white" + if key in {"ABC", 'فا', '۱۲۳', "123", "English", 'انگلیسی', 'فارسی', 'حذف', 'بستن', "Back", '⌫'}: + return KEY_MUTED, "#A8AFB9", KEY_TEXT + return KEY_FACE, "#F0F0F0", KEY_TEXT + + def _keyboard_action_style(self, action): + if action in {"fa", "en", "numeric"}: + active = self.current_keyboard_layout == action + return (PRIMARY, PRIMARY_DARK, "white") if active else (ACCENT, ACCENT_DARK, "#3A2514") + if action == "backspace": + return KEY_MUTED, "#E4D4C2", TEXT + return "#F4EFE9", "#E8D8C7", TEXT + + def _keyboard_action_command(self, action): + if action in {"fa", "en", "numeric"}: + return lambda: (self._register_keyboard_interaction(), self._show_virtual_keyboard(action)) + if action == "backspace": + return lambda: (self._register_keyboard_interaction(), self._backspace_active_input()) + if action == "close": + return lambda: (self._register_keyboard_interaction(), self._hide_virtual_keyboard()) + return lambda: None + + def _refresh_keyboard_action_bar(self): + pass + + def _render_keyboard(self, layout_name): + for child in self.keyboard_keys.winfo_children(): + child.destroy() + + key_height = 44 if self.compact_mode else 40 + key_font_size = 17 if self.compact_mode else 15 + for row_index, row in enumerate(KEYBOARD_LAYOUTS.get(layout_name, KEYBOARD_LAYOUTS["fa"])): + row_frame = ctk.CTkFrame(self.keyboard_keys, fg_color="transparent") + row_frame.grid(row=row_index, column=0, sticky="ew", pady=2 if self.compact_mode else 3) + for column_index, key in enumerate(row): + row_frame.grid_columnconfigure(column_index, weight=self._keyboard_weight(key)) + fg_color, hover_color, text_color = self._keyboard_style(key) + RTLButton( + row_frame, + text=key, + height=key_height, + corner_radius=6, + fg_color=fg_color, + hover_color=hover_color, + text_color=text_color, + font=ctk.CTkFont(family=FONT_BODY, size=key_font_size, weight="bold"), + command=lambda value=key: (self._register_keyboard_interaction(), self._apply_virtual_key(value)), + ).grid(row=0, column=column_index, padx=2, sticky="ew") + + def _apply_virtual_key(self, key): + if key in {"ABC", "English", 'انگلیسی'}: + self._show_virtual_keyboard("en") + return + if key in {'فا', 'فارسی'}: + self._show_virtual_keyboard("fa") + return + if key in {'۱۲۳', "123"}: + self._show_virtual_keyboard("numeric") + return + if key in {'فاصله', "Space"}: + self._insert_into_active_input(" ") + return + if key in {'حذف', "Back", '⌫'}: + self._backspace_active_input() + return + if key == 'بستن': + self._hide_virtual_keyboard() + return + if key in {'تایید', "Enter"}: + self._submit_active_input() + return + self._insert_into_active_input(key) + + def _insert_into_active_input(self, text): + widget = self.active_input + if widget is None or not widget.winfo_exists(): + return + target = self._input_target(widget) + try: + if isinstance(widget, ctk.CTkTextbox): + selection = target.tag_ranges("sel") + if len(selection) == 2: + target.delete(selection[0], selection[1]) + target.insert("insert", text) + target.see("insert") + else: + try: + if target.selection_present(): + target.delete("sel.first", "sel.last") + except Exception: + pass + target.insert("insert", text) + target.focus_set() + self._update_keyboard_preview() + except TclError: + self._hide_virtual_keyboard() + + def _backspace_active_input(self): + widget = self.active_input + if widget is None or not widget.winfo_exists(): + return + target = self._input_target(widget) + try: + if isinstance(widget, ctk.CTkTextbox): + selection = target.tag_ranges("sel") + if len(selection) == 2: + target.delete(selection[0], selection[1]) + elif target.compare("insert", ">", "1.0"): + target.delete("insert-1c") + target.see("insert") + else: + try: + if target.selection_present(): + target.delete("sel.first", "sel.last") + target.focus_set() + self._update_keyboard_preview() + return + except Exception: + pass + index = target.index("insert") + if index > 0: + target.delete(index - 1) + target.focus_set() + self._update_keyboard_preview() + except TclError: + self._hide_virtual_keyboard() + + def _submit_active_input(self): + widget = self.active_input + if widget is None or not widget.winfo_exists(): + return + meta = self._text_inputs.get(widget, {}) + submit = meta.get("submit") + if submit is not None: + submit() + return + if meta.get("multiline"): + self._insert_into_active_input("\n") + return + self._hide_virtual_keyboard() + + def _build_shell(self): + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(0, weight=1) + self.grid_rowconfigure(1, weight=0) + + self.root_frame = ctk.CTkFrame(self, fg_color=BACKGROUND, corner_radius=0) + self.root_frame.grid(row=0, column=0, sticky="nsew") + self.root_frame.grid_columnconfigure(1, weight=1) + self.root_frame.grid_rowconfigure(0, weight=1) + + self.keyboard_host = ctk.CTkFrame(self, fg_color=BACKGROUND, corner_radius=0) + self.keyboard_host.grid(row=1, column=0, sticky="ew") + self.keyboard_host.grid_columnconfigure(0, weight=1) + + self.keyboard_frame = ctk.CTkFrame( + self.keyboard_host, + fg_color=KEYBOARD_BG, + corner_radius=0, + border_width=1, + border_color="#C0C5CB", + ) + self.keyboard_frame.grid(row=0, column=0, sticky="ew", padx=0, pady=0) + self.keyboard_frame.grid_columnconfigure(0, weight=1) + + keyboard_header = ctk.CTkFrame(self.keyboard_frame, fg_color="transparent") + keyboard_header.grid(row=0, column=0, sticky="ew", padx=10, pady=(8, 4)) + keyboard_header.grid_columnconfigure(0, weight=1) + + self.keyboard_preview = RTLLabel( + keyboard_header, + text="", + fg_color="#FFFFFF", + text_color="#000000", + corner_radius=8, + padx=12, + justify="right", + font=ctk.CTkFont(family=FONT_BODY, size=16), + ) + self.keyboard_preview.grid(row=0, column=0, sticky="ew", padx=(6, 12), ipady=8) + + RTLButton( + keyboard_header, + text="بستن کیبورد", + width=76, + height=36, + corner_radius=8, + fg_color=KEY_MUTED, + hover_color="#A8AFB9", + text_color=KEY_TEXT, + font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), + command=self._hide_virtual_keyboard, + ).grid(row=0, column=1, padx=(4, 6), sticky="e") + + self.keyboard_hint = RTLLabel( + self.keyboard_frame, + text="", + text_color="#636A73", + font=ctk.CTkFont(family=FONT_BODY, size=13), + ) + self.keyboard_hint.grid(row=1, column=0, padx=14, pady=(0, 2), sticky="e") + + self.keyboard_action_bar = ctk.CTkFrame(self.keyboard_frame, fg_color="transparent", height=0) + + self.keyboard_keys = ctk.CTkFrame(self.keyboard_frame, fg_color="transparent") + self.keyboard_keys.grid(row=2, column=0, sticky="ew", padx=6, pady=(2, 12)) + self.keyboard_keys.grid_columnconfigure(0, weight=1) + + self._refresh_keyboard_action_bar() + self._render_keyboard(self.current_keyboard_layout) + self.keyboard_host.grid_remove() + + def _reset_text_input_registry(self): + self.active_input = None + self._text_inputs.clear() + self._input_aliases.clear() + + def _clear_root(self): + self._hide_virtual_keyboard() + self._reset_text_input_registry() + for widget in self.root_frame.winfo_children(): + widget.destroy() + + 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.place( + relx=0.5, + rely=0.5, + anchor="center", + relwidth=0.86 if self.is_portrait else 0.54, + relheight=0.52 if self.is_portrait else 0.66, + ) + title = 'راه\u200cاندازی امن برنامه' if not self.controller.is_bootstrapped() else 'ورود به برنامه' + subtitle = ( + 'یک رمز اصلی تعیین کن تا کلیدها و داده\u200cهای حساس داخل دیتابیس به صورت رمز\u200cشده نگه\u200cداری شوند.' + if not self.controller.is_bootstrapped() + else 'برای ورود، رمز اصلی برنامه را وارد کن.' + ) + RTLLabel( + frame, + text=title, + font=ctk.CTkFont(family=FONT_BODY, size=28, weight="bold"), + text_color=TEXT, + ).pack(pady=(30, 10)) + RTLLabel( + frame, + text=subtitle, + wraplength=min(self.window_width - 110, 420), + justify="right", + font=ctk.CTkFont(family=FONT_BODY, size=16), + text_color=MUTED, + ).pack(padx=28) + self.password_entry = RTLEntry( + frame, + placeholder_text='رمز اصلی', + show="*", + height=48, + font=ctk.CTkFont(family=FONT_BODY, size=17), + ) + self.password_entry.pack(fill="x", padx=42, pady=(24, 12)) + 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), + ) + self.confirm_entry.pack(fill="x", padx=42, pady=8) + self.lock_message = RTLLabel( + frame, + text="", + text_color=DANGER, + font=ctk.CTkFont(family=FONT_BODY, size=14 if self.is_portrait else 15), + ) + self.lock_message.pack(pady=(6, 10)) + action_text = 'شروع برنامه' if not self.controller.is_bootstrapped() else 'ورود' + RTLButton( + frame, + text=action_text, + height=48, + fg_color=PRIMARY, + hover_color=PRIMARY_DARK, + command=self._submit_lock_screen, + font=ctk.CTkFont(family=FONT_BODY, size=18, weight="bold"), + ).pack(fill="x", padx=42, pady=(8, 12)) + 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: + self.confirm_entry.bind("", lambda _event: self._submit_lock_screen()) + self._register_text_input(self.confirm_entry, title='تکرار رمز', layout="en", submit=self._submit_lock_screen) + self.after(80, lambda: self._focus_registered_input(self.password_entry)) + + def _configure_root_layout(self): + for column in range(2): + self.root_frame.grid_columnconfigure(column, weight=0, minsize=0) + for row in range(2): + self.root_frame.grid_rowconfigure(row, weight=0, minsize=0) + if self.is_portrait: + top_height = min(max(270, int(self.window_height * 0.36)), 320) + self.root_frame.grid_columnconfigure(0, weight=1) + self.root_frame.grid_rowconfigure(0, weight=0, minsize=top_height) + self.root_frame.grid_rowconfigure(1, weight=1) + else: + self.root_frame.grid_columnconfigure(0, weight=0, minsize=248) + self.root_frame.grid_columnconfigure(1, weight=1) + self.root_frame.grid_rowconfigure(0, weight=1) + + def _configure_profile_card_layout(self): + for widget in ( + self.profile_title_label, + self.profile_name, + self.profile_phone, + self.profile_hint, + self.secure_button, + self.normal_button, + ): + widget.grid_forget() + + if self.is_portrait: + self.profile_card.grid_columnconfigure(0, weight=1) + self.profile_card.grid_columnconfigure(1, weight=1) + self.profile_title_label.grid(row=0, column=0, columnspan=2, padx=14, pady=(14, 6), sticky="e") + self.profile_name.configure(font=ctk.CTkFont(family=FONT_BODY, size=17, weight="bold")) + self.profile_name.grid(row=1, column=0, padx=14, sticky="e") + self.profile_phone.configure(font=ctk.CTkFont(family=FONT_BODY, size=13 if self.is_portrait else 14)) + self.profile_phone.grid(row=2, column=0, padx=14, pady=(0, 8), sticky="e") + self.profile_hint.configure(wraplength=min(self.window_width - 90, 340), font=ctk.CTkFont(family=FONT_BODY, size=14)) + self.profile_hint.grid(row=3, column=0, columnspan=2, padx=14, pady=(0, 10), sticky="e") + self.secure_button.configure(height=42, font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold")) + self.secure_button.grid(row=1, column=1, padx=14, pady=(2, 6), sticky="ew") + self.normal_button.configure(height=40, font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold")) + self.normal_button.grid(row=2, column=1, padx=14, pady=(0, 8), sticky="ew") + else: + self.profile_card.grid_columnconfigure(0, weight=1) + self.profile_title_label.grid(row=0, column=0, padx=18, pady=(18, 6), sticky="e") + self.profile_name.configure(font=ctk.CTkFont(family=FONT_BODY, size=19, weight="bold")) + self.profile_name.grid(row=1, column=0, padx=18, sticky="e") + self.profile_phone.configure(font=ctk.CTkFont(family=FONT_BODY, size=16)) + self.profile_phone.grid(row=2, column=0, padx=18, pady=(0, 18), sticky="e") + self.profile_hint.configure(wraplength=220, font=ctk.CTkFont(family=FONT_BODY, size=15)) + self.profile_hint.grid(row=3, column=0, padx=18, pady=(0, 18), sticky="e") + self.secure_button.configure(height=48, font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold")) + self.secure_button.grid(row=4, column=0, padx=18, pady=(0, 10), sticky="ew") + self.normal_button.configure(height=44, font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold")) + self.normal_button.grid(row=5, column=0, padx=18, pady=(0, 16), sticky="ew") + + def _submit_lock_screen(self): + password = self.password_entry.get().strip() + if len(password) < 6: + self.lock_message.configure(text="رمز باید حداقل ۶ کاراکتر باشد.") + return + if not self.controller.is_bootstrapped(): + confirm = self.confirm_entry.get().strip() + if password != confirm: + self.lock_message.configure(text="تکرار رمز با رمز اصلی یکسان نیست.") + return + self.controller.bootstrap(password) + self._build_main_app() + return + if self.controller.unlock(password): + self._build_main_app() + return + self.lock_message.configure(text="رمز وارد شده درست نیست.") + + def _build_main_app(self): + self._clear_root() + self._configure_root_layout() + outer_pad = 12 if self.is_portrait else 18 + inner_pad = 8 if self.is_portrait else 12 + title_size = 24 if self.is_portrait else 28 + subtitle_size = 12 if self.is_portrait else 13 + action_height = 46 if self.is_portrait else 42 + 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.grid(row=0, column=0, sticky="nsew") + self.sidebar.grid_columnconfigure(0, weight=1) + self.sidebar.grid_rowconfigure(5, weight=1) + + RTLLabel( + self.sidebar, + text='صبا', + text_color="white", + font=ctk.CTkFont(family=FONT_TITLE, size=title_size, weight="bold"), + ).grid(row=0, column=0, padx=20, pady=(16, 2), sticky="e") + RTLLabel( + self.sidebar, + text='پیام\u200cرسان امن و ساده برای کاربر غیر فنی', + text_color="#D5E8E1", + font=ctk.CTkFont(family=FONT_BODY, size=subtitle_size), + ).grid(row=1, column=0, padx=20, 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, + ) + self.connection_badge.grid(row=2, column=0, padx=20, pady=(12, 10), sticky="e") + + 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_columnconfigure((0, 1), weight=1) + RTLButton( + top_actions, + text='مخاطب جدید', + command=self._open_contact_dialog, + fg_color=ACCENT, + text_color="#3A2514", + hover_color=ACCENT_DARK, + 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") + RTLButton( + top_actions, + text='تنظیمات', + command=self._open_settings_panel, + fg_color="#F4EFE9", + text_color="#15302B", + hover_color="#ECE1D5", + 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") + + self.contact_form_card = ctk.CTkFrame( + self.sidebar, + fg_color=SIDEBAR_SOFT, + corner_radius=18, + border_width=1, + border_color="#3B7B66", + ) + 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) + RTLLabel( + self.contact_form_card, + text='مخاطب جدید', + text_color="white", + font=ctk.CTkFont(family=FONT_BODY, size=17, weight="bold"), + ).grid(row=0, column=0, padx=14, pady=(14, 8), sticky="e") + self.contact_name_entry = RTLEntry( + self.contact_form_card, + placeholder_text='نام مخاطب', + height=44, + font=ctk.CTkFont(family=FONT_BODY, size=14 if self.is_portrait else 15), + ) + self.contact_name_entry.grid(row=1, column=0, padx=14, pady=6, sticky="ew") + self.contact_phone_entry = RTLEntry( + self.contact_form_card, + placeholder_text='شماره موبایل', + height=44, + font=ctk.CTkFont(family=FONT_BODY, size=14 if self.is_portrait else 15), + ) + self.contact_phone_entry.grid(row=2, column=0, padx=14, pady=6, sticky="ew") + self.contact_form_message = RTLLabel( + self.contact_form_card, + text="", + text_color="#FDE68A", + font=ctk.CTkFont(family=FONT_BODY, size=13), + ) + self.contact_form_message.grid(row=3, column=0, padx=14, pady=(4, 2), sticky="e") + contact_actions = ctk.CTkFrame(self.contact_form_card, fg_color="transparent") + contact_actions.grid(row=4, column=0, padx=10, pady=(4, 14), sticky="ew") + contact_actions.grid_columnconfigure((0, 1), weight=1) + RTLButton( + contact_actions, + text='ذخیره', + fg_color=ACCENT, + text_color="#3A2514", + hover_color=ACCENT_DARK, + command=self._save_contact_inline, + 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", + text_color=TEXT, + hover_color="#ECE1D5", + command=self._hide_contact_form, + font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), + height=40, + ).grid(row=0, column=1, padx=4, sticky="ew") + self.contact_phone_entry.bind("", lambda _event: self._save_contact_inline()) + self._register_text_input(self.contact_name_entry, title="نام مخاطب", layout="fa") + self._register_text_input( + self.contact_phone_entry, + title="شماره موبایل", + layout="numeric", + submit=self._save_contact_inline, + ) + self.contact_form_card.grid_remove() + + 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"), + fg_color="transparent", + ) + self.contacts_frame.grid(row=5, column=0, padx=12, pady=8, 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.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"), + ) + self.chat_title.grid(row=0, column=0, padx=22, pady=(18, 4), sticky="e") + self.chat_subtitle = RTLLabel( + self.header_card, + text='در اینجا فقط دو حالت داری: عادی یا امن', + text_color=MUTED, + font=ctk.CTkFont(family=FONT_BODY, size=14), + ) + self.chat_subtitle.grid(row=1, column=0, padx=22, pady=(0, 18), 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"), + ) + self.mode_badge.grid(row=0, column=1, rowspan=2, padx=20, 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") + if self.is_portrait: + content.grid_rowconfigure(0, weight=1) + content.grid_rowconfigure(1, weight=0) + content.grid_columnconfigure(0, weight=1) + else: + content.grid_rowconfigure(0, weight=1) + content.grid_columnconfigure(0, weight=1) + content.grid_columnconfigure(1, weight=0, minsize=220) + + self.chat_container = RTLScrollableFrame( + content, + fg_color="transparent" + ) + if self.is_portrait: + self.chat_container.grid(row=0, column=0, sticky="nsew", pady=(0, inner_pad)) + 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.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( + self.profile_card, + text='پروفایل مخاطب', + text_color=TEXT, + font=ctk.CTkFont(family=FONT_TITLE, size=17 if self.is_portrait else 18, weight="bold"), + ) + self.profile_name = RTLLabel( + self.profile_card, + text='نام مخاطب', + text_color=TEXT, + font=ctk.CTkFont(family=FONT_BODY, size=19, weight="bold"), + ) + self.profile_name.grid(row=1, column=0, padx=18, sticky="e") + self.profile_phone = RTLLabel( + self.profile_card, + text='شماره', + text_color=MUTED, + font=ctk.CTkFont(family=FONT_BODY, size=16), + ) + self.profile_phone.grid(row=2, column=0, padx=18, pady=(0, 18), sticky="e") + self.profile_hint = RTLLabel( + self.profile_card, + text='برای این مخاطب هنوز حالت امن فعال نشده است.', + wraplength=220, + justify="right", + text_color=MUTED, + font=ctk.CTkFont(family=FONT_BODY, size=15), + ) + self.profile_hint.grid(row=3, column=0, padx=18, pady=(0, 18), sticky="e") + self.secure_button = RTLButton( + self.profile_card, + text='فعال\u200cسازی ارتباط امن', + fg_color=PRIMARY, + hover_color=PRIMARY_DARK, + font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"), + command=self._toggle_secure_mode, + height=48, + ) + self.secure_button.grid(row=4, column=0, padx=18, pady=(0, 10), sticky="ew") + self.normal_button = RTLButton( + self.profile_card, + text='بازگشت به حالت عادی', + fg_color="#F4EFE9", + text_color=TEXT, + hover_color="#ECE1D5", + font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"), + command=self._switch_to_normal, + height=44, + ) + self.normal_button.grid(row=5, column=0, padx=18, pady=(0, 16), 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.grid_columnconfigure(0, weight=1) + self.message_entry = RTLTextbox( + composer, + height=72 if self.is_portrait else 82, + fg_color=INPUT_BG, + border_width=0, + font=ctk.CTkFont(family=FONT_BODY, size=16), + wrap="word", + ) + self.message_entry.grid(row=0, column=0, padx=(16, inner_pad), pady=14 if self.is_portrait else 16, 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") + self.send_state_label = RTLLabel( + actions, + text="", + text_color=MUTED, + font=ctk.CTkFont(family=FONT_BODY, size=14), + ) + self.send_state_label.pack(pady=(4, 8)) + 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, + height=48, + command=self._send_message, + ).pack() + self.message_entry.bind("", lambda _event: self._send_message()) + self._register_text_input(self.message_entry, title="متن پیام", layout="fa", multiline=True) + + self.overlay_frame = ctk.CTkFrame( + self.main_panel, + fg_color=CARD, + corner_radius=28, + border_width=1, + border_color=BORDER, + ) + self.overlay_frame.grid(row=0, column=0, rowspan=3, padx=outer_pad, pady=outer_pad, sticky="nsew") + self.overlay_frame.grid_columnconfigure(0, weight=1) + self.overlay_frame.grid_remove() + + self.refresh_all() + + def refresh_all(self): + self._refresh_connection_badge() + self._refresh_contacts() + self._refresh_current_chat() + self.after(0, self._hide_mouse_cursor) + + def handle_background_refresh(self, phone=None): + self.refresh_all() + + def _refresh_connection_badge(self): + modem = self.controller.modem_status() + if modem["connected"]: + self.connection_badge.configure(text=f"مودم متصل | {modem['port']}", fg_color="#2E7D62") + else: + self.connection_badge.configure(text=f"مودم آفلاین | {modem['port']}", fg_color="#9A6C3C") + + def _refresh_contacts(self): + for widget in self.contacts_frame.winfo_children(): + widget.destroy() + contacts = self.controller.list_contacts() + if not contacts: + RTLLabel( + self.contacts_frame, + text='هنوز مخاطبی اضافه نشده است.', + text_color="#D5E8E1", + font=ctk.CTkFont(family=FONT_BODY, size=16), + ).grid(row=0, column=0, padx=12, pady=12, sticky="e") + return + for index, contact in enumerate(contacts): + selected = self.current_contact_phone == contact.phone + card = RTLButton( + 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, + 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), + ) + card.grid(row=index, column=0, padx=8, pady=6, sticky="ew") + + def _select_contact(self, phone: str): + self.current_contact_phone = phone + self._refresh_contacts() + self._refresh_current_chat() + + def _refresh_current_chat(self): + if not self.current_contact_phone: + self.chat_title.configure(text='یک مخاطب را انتخاب کن') + self.chat_subtitle.configure(text='در اینجا فقط دو حالت داری: عادی یا امن') + self.mode_badge.configure(text='عادی', fg_color=PRIMARY_SOFT, text_color=PRIMARY) + self.profile_name.configure(text='نام مخاطب') + self.profile_phone.configure(text='شماره') + self.profile_hint.configure(text='برای شروع، یک مخاطب از ستون سمت راست انتخاب کن.') + self._render_messages([]) + return + contact = self.controller.get_contact(self.current_contact_phone) + messages = self.controller.get_messages(self.current_contact_phone) + self.chat_title.configure(text=contact.name) + self.chat_subtitle.configure(text=contact.phone) + self.profile_name.configure(text=contact.name) + self.profile_phone.configure(text=contact.phone) + if contact.secure_state == "pending": + self.mode_badge.configure(text='در انتظار', fg_color="#FCEBD7", text_color="#9A6C3C") + self.profile_hint.configure(text='درخواست ارتباط امن ارسال شده و برنامه منتظر پاسخ طرف مقابل است.') + elif contact.mode == "secure": + self.mode_badge.configure(text='امن', fg_color="#D9F5E8", text_color="#0F8A5F") + self.profile_hint.configure(text='ارتباط امن فعال است. هر زمان بخواهی می\u200cتوانی به حالت عادی برگردی.') + else: + self.mode_badge.configure(text='عادی', fg_color=PRIMARY_SOFT, text_color=PRIMARY) + if contact.has_peer_key: + self.profile_hint.configure(text='کلید این مخاطب آماده است. اگر بخواهی می\u200cتوانی دوباره ارتباط امن را فعال کنی.') + else: + self.profile_hint.configure(text='برای امن شدن گفتگو، فقط روی دکمه فعال\u200cسازی ارتباط امن بزن.') + self.secure_button.configure(state="normal") + self.normal_button.configure(state="normal" if contact.mode == "secure" or contact.secure_state == "pending" else "disabled") + self._render_messages(messages) + + def _render_messages(self, messages): + for widget in self.chat_container.winfo_children(): + widget.destroy() + + if not messages: + RTLLabel( + self.chat_container, + text='هنوز پیامی ثبت نشده است.\nاز نوار پایین برای نوشتن پیام استفاده کن.', + text_color=MUTED, + font=ctk.CTkFont(family=FONT_BODY, size=15) + ).pack(pady=40) + return + + for message in messages: + if message.direction == "system": + RTLLabel( + self.chat_container, + 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") + continue + + is_out = message.direction == "out" + bubble_color = "#E1F2E9" if is_out else "#FFFFFF" + 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 + ) + bubble.pack(anchor=anchor, padx=12, pady=6, 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)), + justify="right" + ).pack(padx=16, pady=(12, 4), 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), + justify="right" + ).pack(padx=16, pady=(0, 8), anchor="w" if is_out else "e") + + try: + self.after(50, lambda: self.chat_container._parent_canvas.yview_moveto(1.0)) + except Exception: + pass + + def _send_message(self): + if not self.current_contact_phone: + self.send_state_label.configure(text='اول یک مخاطب را انتخاب کن.', text_color=DANGER) + return + text = self.message_entry.get("1.0", "end-1c").strip() + if not text: + self.send_state_label.configure(text='متن پیام خالی است.', text_color=DANGER) + return + ok, state = self.controller.send_message(self.current_contact_phone, text) + if ok: + self.message_entry.delete("1.0", "end") + label = 'ارسال شد.' if state == "sent" else 'در حالت آفلاین، پیام به صورت شبیه\u200cسازی ثبت شد.' + self.send_state_label.configure(text=label, text_color=PRIMARY) + else: + self.send_state_label.configure(text=state, text_color=DANGER) + self.refresh_all() + + def _toggle_secure_mode(self): + if not self.current_contact_phone: + return + ok, state = self.controller.request_secure(self.current_contact_phone) + if ok: + self.send_state_label.configure( + text='درخواست ارتباط امن ارسال شد.' if state == "sent" else 'در حالت آفلاین، درخواست امن به صورت محلی ثبت شد.', + text_color=PRIMARY, + ) + else: + self.send_state_label.configure(text='ارسال درخواست امن ناموفق بود.', text_color=DANGER) + self.refresh_all() + + def _switch_to_normal(self): + if not self.current_contact_phone: + return + ok, state = self.controller.switch_to_normal(self.current_contact_phone) + if ok: + self.send_state_label.configure( + text='گفتگو به حالت عادی برگشت.' if state == "sent" else 'در حالت آفلاین، بازگشت به عادی محلی ثبت شد.', + text_color=PRIMARY, + ) + else: + self.send_state_label.configure(text='بازگشت به حالت عادی انجام نشد.', text_color=DANGER) + self.refresh_all() + + def _open_contact_dialog(self): + if self.contact_form_card.winfo_ismapped(): + self._hide_contact_form() + return + self.contact_form_message.configure(text="") + self.contact_name_entry.delete(0, "end") + self.contact_phone_entry.delete(0, "end") + self.contact_form_card.grid() + self.after(80, lambda: self._focus_registered_input(self.contact_name_entry)) + + def _hide_contact_form(self): + self._hide_virtual_keyboard() + self.contact_form_card.grid_remove() + self.contact_form_message.configure(text="") + + def _save_contact_inline(self): + name = self.contact_name_entry.get().strip() + phone = self.contact_phone_entry.get().strip() + if not name or not phone: + self.contact_form_message.configure(text='نام و شماره هر دو لازم هستند.') + return + self.controller.save_contact(name, phone) + self.current_contact_phone = phone + self.contact_form_message.configure(text='مخاطب ذخیره شد.') + self.contact_name_entry.delete(0, "end") + self.contact_phone_entry.delete(0, "end") + self._hide_virtual_keyboard() + self.refresh_all() + self.after(900, self._hide_contact_form) + + def _open_settings_panel(self): + self._show_overlay() + header = self._build_overlay_header( + 'تنظیمات', + 'همه بخش\u200cها داخل همین پنجره باز می\u200cشوند تا برای نمایشگر ۷ اینچی ساده و قابل لمس بمانند.', + ) + header.pack(fill="x", padx=16, pady=(16, 10)) + + body = ctk.CTkFrame(self.overlay_frame, fg_color=SURFACE, corner_radius=22) + body.pack(fill="both", expand=True, padx=16, pady=(0, 16)) + + modem = self.controller.modem_status() + RTLLabel( + body, + text='وضعیت فعلی دستگاه', + text_color=TEXT, + font=ctk.CTkFont(family=FONT_TITLE, size=18, weight="bold"), + ).pack(anchor="e", padx=18, pady=(18, 6)) + RTLLabel( + body, + text=f"مودم: {'متصل' if modem['connected'] else 'آفلاین'} | پورت: {modem['port']} | Baudrate: {modem['baudrate']}", + text_color=MUTED, + wraplength=min(self.window_width - 96, 480), + justify="right", + font=ctk.CTkFont(family=FONT_BODY, size=14), + ).pack(anchor="e", padx=18, pady=(0, 14)) + RTLButton( + body, + text='ورود به پنل ادمین', + fg_color=PRIMARY, + hover_color=PRIMARY_DARK, + font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"), + height=42, + command=self._open_admin_login, + ).pack(fill="x", padx=18, pady=(8, 8)) + RTLButton( + body, + text='بازگشت به گفتگو', + fg_color="#F1E7DB", + text_color=TEXT, + hover_color="#E8D8C7", + font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), + height=40, + command=self._hide_overlay, + ).pack(fill="x", padx=18, pady=(0, 18)) + + def _show_overlay(self): + self._hide_virtual_keyboard() + for widget in self.overlay_frame.winfo_children(): + widget.destroy() + self.overlay_frame.grid() + self.overlay_frame.lift() + self.after(0, self._hide_mouse_cursor) + + def _hide_overlay(self): + self._hide_virtual_keyboard() + for widget in self.overlay_frame.winfo_children(): + widget.destroy() + self.overlay_frame.grid_remove() + self.after(0, self._hide_mouse_cursor) + + def _build_overlay_header(self, title: str, subtitle: str): + header = ctk.CTkFrame(self.overlay_frame, fg_color="transparent") + header.grid_columnconfigure(0, weight=1) + RTLButton( + header, + text='بستن', + width=86, + height=36, + fg_color="#F1E7DB", + text_color=TEXT, + hover_color="#E8D8C7", + font=ctk.CTkFont(family=FONT_BODY, size=14, weight="bold"), + command=self._hide_overlay, + ).grid(row=0, column=1, padx=(8, 0), sticky="e") + RTLLabel( + header, + text=title, + text_color=TEXT, + font=ctk.CTkFont(family=FONT_TITLE, size=22, weight="bold"), + ).grid(row=0, column=0, sticky="e") + RTLLabel( + header, + text=subtitle, + text_color=MUTED, + wraplength=min(self.window_width - 96, 500), + justify="right", + font=ctk.CTkFont(family=FONT_BODY, size=13), + ).grid(row=1, column=0, columnspan=2, pady=(4, 0), sticky="e") + return header + + def _open_admin_login(self): + self._show_overlay() + header = self._build_overlay_header( + 'ورود ادمین', + 'جزئیات فنی، لاگ\u200cها و تنظیمات امنیتی فقط بعد از ورود ادمین نمایش داده می\u200cشوند.', + ) + header.pack(fill="x", padx=16, pady=(16, 10)) + + body = ctk.CTkFrame(self.overlay_frame, fg_color=SURFACE, corner_radius=22) + body.pack(fill="both", expand=True, padx=16, pady=(0, 16)) + RTLLabel( + body, + text='رمز اصلی را وارد کن', + text_color=TEXT, + font=ctk.CTkFont(family=FONT_BODY, size=18, weight="bold"), + ).pack(anchor="e", padx=18, pady=(22, 10)) + password = RTLEntry( + body, + placeholder_text='رمز اصلی', + show="*", + height=46, + font=ctk.CTkFont(family=FONT_BODY, size=15), + ) + password.pack(fill="x", padx=18, pady=6) + message = RTLLabel(body, text="", text_color=DANGER, font=ctk.CTkFont(family=FONT_BODY, size=14)) + message.pack(anchor="e", padx=18, pady=(4, 8)) + + def submit(): + if not self.controller.verify_password(password.get().strip()): + message.configure(text='رمز ادمین صحیح نیست.') + return + self._open_admin_panel() + + RTLButton( + body, + text='ورود به پنل ادمین', + fg_color=PRIMARY, + hover_color=PRIMARY_DARK, + font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"), + height=42, + command=submit, + ).pack(fill="x", padx=18, pady=(8, 8)) + RTLButton( + body, + text='بازگشت به تنظیمات', + fg_color="#F1E7DB", + text_color=TEXT, + hover_color="#E8D8C7", + font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), + height=40, + command=self._open_settings_panel, + ).pack(fill="x", padx=18, pady=(0, 18)) + self._register_text_input(password, title="رمز اصلی ادمین", layout="en", submit=submit) + self.after(80, lambda: self._focus_registered_input(password)) + def _open_admin_panel(self): + self._show_overlay() + header = self._build_overlay_header( + 'پنل ادمین', + 'این بخش برای نمایشگر کوچک خلاصه شده تا اطلاعات اصلی، لاگ\u200cها و تنظیمات مودم/رمز داخل همان پنجره در دسترس باشند.', + ) + header.pack(fill="x", padx=16, pady=(16, 10)) + + snapshot = self.controller.get_admin_snapshot() + stats = snapshot["stats"] + system_info = snapshot["system_info"] + + body = RTLScrollableFrame( + self.overlay_frame, + fg_color=SURFACE, + corner_radius=22, + label_text="", + ) + body.pack(fill="both", expand=True, padx=16, pady=(0, 16)) + body.grid_columnconfigure((0, 1), weight=1) + + stat_labels = [ + ('کل مخاطب\u200cها', stats["contacts"]), + ('مخاطب امن', stats["secure_contacts"]), + ('در انتظار', stats["pending_contacts"]), + ('پیام امن', stats["secure_messages"]), + ] + for index, (title, value) in enumerate(stat_labels): + card = ctk.CTkFrame(body, fg_color=CARD, corner_radius=18) + card.grid(row=0, column=index % 2, padx=8, pady=8, sticky="ew") + RTLLabel( + card, + text=title, + text_color=MUTED, + font=ctk.CTkFont(family=FONT_BODY, size=13), + ).pack(anchor="e", padx=12, pady=(10, 2)) + RTLLabel( + card, + text=str(value), + text_color=TEXT, + font=ctk.CTkFont(family=FONT_BODY, size=18, weight="bold"), + ).pack(anchor="e", padx=12, pady=(0, 10)) + + system_card = ctk.CTkFrame(body, fg_color=CARD, corner_radius=18) + system_card.grid(row=1, column=0, columnspan=2, padx=8, pady=8, sticky="ew") + RTLLabel( + system_card, + text='اطلاعات سیستمی', + text_color=TEXT, + font=ctk.CTkFont(family=FONT_TITLE, size=18, weight="bold"), + ).pack(anchor="e", padx=12, pady=(12, 4)) + RTLLabel( + system_card, + text=f"پلتفرم: {system_info['platform']}\nپورت: {system_info['modem_port']} | Baudrate: {system_info['baudrate']}\nاثر انگشت کلید: {system_info['fingerprint']}", + text_color=MUTED, + justify="right", + wraplength=min(self.window_width - 96, 500), + font=ctk.CTkFont(family=FONT_BODY, size=13), + ).pack(anchor="e", padx=12, pady=(0, 12)) + + logs_card = ctk.CTkFrame(body, fg_color=CARD, corner_radius=18) + logs_card.grid(row=2, column=0, columnspan=2, padx=8, pady=8, sticky="ew") + RTLLabel( + logs_card, + text='آخرین لاگ\u200cها', + text_color=TEXT, + font=ctk.CTkFont(family=FONT_TITLE, size=18, weight="bold"), + ).pack(anchor="e", padx=12, pady=(12, 4)) + log_text = RTLTextbox(logs_card, height=140, fg_color=SURFACE, font=ctk.CTkFont(family=FONT_BODY, size=13), wrap="word") + log_text.pack(fill="x", padx=12, pady=(0, 12)) + if snapshot["events"]: + for event in snapshot["events"][:8]: + log_text.insert("end", f"{event.created_at} | {event.phone}\n{event.details}\n\n") + else: + log_text.insert("end", 'هنوز لاگی ثبت نشده است.') + log_text.configure(state="disabled") + + pending_card = ctk.CTkFrame(body, fg_color=CARD, corner_radius=18) + pending_card.grid(row=3, column=0, columnspan=2, padx=8, pady=8, sticky="ew") + RTLLabel( + pending_card, + text='بسته\u200cهای ناقص', + text_color=TEXT, + font=ctk.CTkFont(family=FONT_TITLE, size=18, weight="bold"), + ).pack(anchor="e", padx=12, pady=(12, 4)) + pending_text = RTLTextbox(pending_card, height=90, fg_color=SURFACE, font=ctk.CTkFont(family=FONT_BODY, size=13), wrap="word") + pending_text.pack(fill="x", padx=12, pady=(0, 12)) + if snapshot["pending_packets"]: + for packet in snapshot["pending_packets"]: + pending_text.insert("end", f"{packet.phone} | {packet.received_parts}/{packet.total_parts}\n") + else: + pending_text.insert("end", 'پیام ناقصی در صف وجود ندارد.') + pending_text.configure(state="disabled") + + settings_card = ctk.CTkFrame(body, fg_color=CARD, corner_radius=18) + settings_card.grid(row=4, column=0, columnspan=2, padx=8, pady=8, sticky="ew") + RTLLabel( + settings_card, + text='تنظیمات مودم و رمز', + text_color=TEXT, + font=ctk.CTkFont(family=FONT_TITLE, size=18, weight="bold"), + ).pack(anchor="e", padx=12, pady=(12, 4)) + port_entry = RTLEntry(settings_card, placeholder_text='پورت مودم مثل COM3', height=38, font=ctk.CTkFont(family=FONT_BODY, size=14)) + port_entry.pack(fill="x", padx=12, pady=6) + port_entry.insert(0, system_info["modem_port"]) + baud_entry = RTLEntry(settings_card, placeholder_text="Baudrate", height=38, font=ctk.CTkFont(family=FONT_BODY, size=14)) + baud_entry.pack(fill="x", padx=12, pady=6) + baud_entry.insert(0, str(system_info["baudrate"])) + current_password = RTLEntry(settings_card, placeholder_text='رمز فعلی', show="*", height=38, font=ctk.CTkFont(family=FONT_BODY, size=14)) + current_password.pack(fill="x", padx=12, pady=6) + new_password = RTLEntry(settings_card, placeholder_text='رمز جدید', show="*", height=38, font=ctk.CTkFont(family=FONT_BODY, size=14)) + new_password.pack(fill="x", padx=12, pady=6) + admin_message = RTLLabel(settings_card, text="", text_color=DANGER, font=ctk.CTkFont(family=FONT_BODY, size=13)) + admin_message.pack(anchor="e", padx=12, pady=(2, 6)) + self._register_text_input(port_entry, title="پورت مودم", layout="en") + self._register_text_input(baud_entry, title="Baudrate", layout="numeric") + self._register_text_input(current_password, title="رمز فعلی", layout="en") + self._register_text_input(new_password, title="رمز جدید", layout="en") + + def save_admin_changes(): + try: + connected = self.controller.reconnect_modem(port_entry.get().strip(), int(baud_entry.get().strip())) + if current_password.get().strip() and new_password.get().strip(): + self.controller.change_master_password(current_password.get().strip(), new_password.get().strip()) + if connected: + admin_message.configure(text='تغییرات ذخیره شد و مودم متصل است.', text_color=PRIMARY) + else: + admin_message.configure(text='تنظیمات ذخیره شد، اما مودم هنوز آفلاین است.', text_color="#9A6C3C") + except Exception as exc: + admin_message.configure(text=str(exc), text_color=DANGER) + + RTLButton( + settings_card, + text='ذخیره تغییرات', + fg_color=PRIMARY, + hover_color=PRIMARY_DARK, + font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), + height=40, + command=save_admin_changes, + ).pack(fill="x", padx=12, pady=(2, 12)) + + + + + diff --git a/secure_sms_v2.db b/secure_sms_v2.db new file mode 100644 index 0000000..4b93813 Binary files /dev/null and b/secure_sms_v2.db differ